Fix rotated table layout issues
This commit is contained in:
fraxle
2026-08-09 09:47:57 +01:00
parent 1bb870eab3
commit dace65b750
4 changed files with 149 additions and 103 deletions
+90 -55
View File
@@ -2542,28 +2542,25 @@
is two tables kept in step by useTableScroll — this is ONE table in a
single scroll pane, so both sticky edges are pure CSS.
The pane scrolls in both axes rather than letting the page scroll
vertically: sticky positioning resolves against the nearest scrolling
ancestor, and making the pane scroll horizontally makes it the
vertical scroll ancestor too. Scrolling both here is what lets the
hour row and the metric column stay pinned at the same time.
A single table in the shared .utci-tbody-scroll horizontal scroller.
The normal orientation splits head from body so its header can stick
to the viewport; here the hour row scrolls away with the table by
design, so that machinery is not needed. The metric column pins with
plain CSS sticky, which works fine inside the scroller. The table
keeps its natural height and the page scrolls normally.
══════════════════════════════════════════════════════════════════════ */
.utci-rotated-scroll {
overflow: auto;
max-height: min(78vh, 900px);
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
cursor: grab; /* drag-to-scroll, same as the normal body scroller */
}
.utci-rotated-scroll:active {
cursor: grabbing;
/* Fallback only, for the first paint before the measurement lands.
useTableScroll measures the pinned column and writes --hour-col-w
inline — it can't be fixed here, because auto layout widens that
column to fit the longest metric name on show. */
.utci-table-wrap.is-rotated {
--hour-col-w: 136px;
}
/* Auto layout — the whole point of the rotated view is that each column
is only as wide as the hour it holds, so the fixed layout the normal
table needs for its JS width-sync is explicitly turned off here. */
.utci-table.utci-table-rotated {
/* Auto layout: with a single table there are no header widths to match,
so each hour column can simply size to its own content. */
table-layout: auto;
width: max-content;
min-width: 100%;
@@ -2594,12 +2591,10 @@
white-space: nowrap;
}
/* ── STICKY HOUR ROW (top) ────────────────────────────────────────── */
/* The hour row is deliberately NOT sticky: pinning it would mean the
metric rows sliding underneath, and with only a dozen of them the
table reads as shrinking away to nothing. It scrolls with the page. */
.utci-table-rotated thead th {
position: sticky;
top: 0;
z-index: 4;
background: #f0e4c4;
vertical-align: middle;
}
@@ -2668,6 +2663,33 @@
background-image: linear-gradient(var(--grp-tint, transparent), var(--grp-tint, transparent));
}
/* The group rules carry a 2px left border that marks where a group STARTS
along the columns. Rotated, groups are stacked rows and have no left
edge to mark, so it shows up as a stray line — and its width offsets
the label 2px against the metric names below it. */
.utci-table.utci-table-rotated tbody .grp-label-row--rotated th {
border-left: 0;
}
/* The group heading pins with the metric names it labels. */
.utci-table.utci-table-rotated tbody th.rot-group-label {
position: sticky;
left: 0;
z-index: 3;
text-align: left;
padding: 5px 16px;
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
}
/* The per-hour cells filling out a divider row. They carry the group tint
and, in the current hour's column, its share of the brass bracket. */
.utci-table.utci-table-rotated tbody td.grp-label-fill {
padding: 5px 0;
min-width: 0;
background: var(--grp-tint, transparent);
border-bottom: 1px solid rgba(74, 50, 24, 0.18);
}
.utci-table.utci-table-rotated tbody th.rot-metric-label {
position: sticky;
left: 0;
@@ -2681,15 +2703,14 @@
/* The corner cell sits in both sticky tracks, so it needs to win over
each of them. */
/* The selector is deliberately over-qualified: the normal orientation's
`.utci-table thead th:first-child` rule pins the Hour heading with
position:relative + a JS transform, and would otherwise out-specify
this and stop the corner sticking. */
/* The corner pins alongside the metric names below it. Over-qualified so
it beats the normal orientation's `.utci-table thead th:first-child`
rule, which sets position:relative for the JS transform this view
doesn't use. */
.utci-table.utci-table-rotated thead th.rot-corner {
position: sticky;
top: 0;
left: 0;
z-index: 6;
z-index: 4;
background: #f0e4c4;
text-align: left;
transform: none;
@@ -2710,12 +2731,10 @@
@media (max-width: 640px) {
/* Icon-only on narrow screens — the toolbar is already tight. */
.table-rotate-btn-label {
display: none;
}
.table-rotate-btn {
padding: 6px 8px;
font-size: 0;
gap: 0;
padding: 6px 7px;
}
}
@@ -2728,11 +2747,6 @@
padding: 5px 16px;
border-right: 0;
}
.utci-table-rotated .grp-label-pin {
position: sticky;
left: 16px;
display: inline-block;
}
/* ── NOW / NIGHT ──────────────────────────────────────────────────────
Rotated, "now" is a column rather than a row, so the brass bracket
@@ -2750,39 +2764,57 @@
color: #7a5c30;
}
/* ── TOOLBAR BUTTON HEIGHTS ───────────────────────────────────────────
Quick/Detailed, Rotate and Edit columns sit on one row and have to
agree on height. They didn't: the first two set line-height:1 while
Edit columns leaves it at normal, so the same 6px padding produced a
box ~3px taller. Pinning one line-height and one min-height across all
three settles it regardless of font-size differences. */
.fvt-btn,
.table-rotate-btn,
.col-toggles-edit-btn {
min-height: 29px;
line-height: 1;
}
/* ── ROTATE BUTTON ────────────────────────────────────────────────────
Sits in the toolbar after the view switcher and Edit columns, and only
while the detailed table is showing. */
Sits beside the Quick/Detailed view toggle and only while the detailed
table is showing. Deliberately mirrors .fvt-btn — same type, padding,
border and states — so it reads as part of that control group rather
than a stray button. Standalone, so it keeps a radius on all corners
where .fvt-btn splits its radius across the segmented pair. */
.table-rotate-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 11px;
border: 1px solid #c9b08a;
border-radius: 6px;
background: #fffcf2;
color: #8a6a34;
gap: 4px;
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1;
padding: 6px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.table-rotate-btn-icon {
font-size: 14px;
line-height: 1;
border: 1.5px solid #c9b08a;
border-radius: 3px;
background: #f5edd6;
color: #6b4228;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.table-rotate-btn:hover {
background: #f6ead0;
color: #6b4f2a;
border-color: #c8922a;
background: #fef3dc;
}
.table-rotate-btn.on {
background: #c8922a;
border-color: #c8922a;
color: #fffcf2;
color: #fff;
}
/* The icon turns with the state it describes — flat when hours run down
the side, quarter-turned when they run along the top. */
.table-rotate-btn .fvt-btn-icon {
transition: transform 0.18s ease;
}
.table-rotate-btn.on .fvt-btn-icon {
transform: rotate(90deg);
}
/* ── MOBILE ───────────────────────────────────────────────────────── */
@@ -2802,4 +2834,7 @@
.utci-table-rotated .rot-metric-label {
min-width: 112px;
}
.utci-table-wrap.is-rotated {
--hour-col-w: 112px;
}
}
+37 -19
View File
@@ -235,7 +235,7 @@ export function UTCIForecast() {
utciEnv, setUtciEnv,
tableRotated, toggleTableRotated,
headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef, rotatedScrollRef,
bodyScrollRef, bodyTableRef, tableWrapRef,
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave,
@@ -1134,6 +1134,20 @@ export function UTCIForecast() {
Detailed
</button>
</span>
${forecastView === 'table' && html`
<button type="button"
class=${'table-rotate-btn' + (tableRotated ? ' on' : '')}
title=${tableRotated ? 'Hours run along the top — click for hours down the side' : 'Hours run down the side — click for hours along the top'}
aria-pressed=${tableRotated ? 'true' : 'false'}
onClick=${toggleTableRotated}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
<path d="M2.4 6.6a3.7 3.7 0 0 1 6-3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.6 5.4a3.7 3.7 0 0 1-6 3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.3 1.1l2.4 2.2-2.4 1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.7 10.9L3.3 8.7l2.4-1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Rotate
</button>`}
<button
class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
@@ -1144,15 +1158,6 @@ export function UTCIForecast() {
<span class="col-toggles-edit-btn-label">${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
</button>
${forecastView === 'table' && html`
<button type="button"
class=${'table-rotate-btn table-rotate-btn--toolbar' + (tableRotated ? ' on' : '')}
title=${tableRotated ? 'Hours run along the top — click for hours down the side' : 'Hours run down the side — click for hours along the top'}
aria-pressed=${tableRotated ? 'true' : 'false'}
onClick=${toggleTableRotated}>
<span class="table-rotate-btn-icon" aria-hidden="true">⭮</span>
<span class="table-rotate-btn-label">Rotate</span>
</button>`}
</span>
${forecastView === 'simple' && (() => {
const showFur = visibleCols.furSurfaceT;
@@ -1420,10 +1425,14 @@ export function UTCIForecast() {
};
// ── ROTATED: hours across the top, metrics down the side ──
// One table, not two: the sticky top row and sticky left
// column are pure CSS here, so none of the head/body width
// syncing in useTableScroll is needed (it is disabled while
// rotated).
// One table, unlike the normal orientation's split pair.
// That split exists purely to let the header stick to the
// viewport while the body scrolls sideways; here the hour
// row deliberately scrolls away with the table, so there is
// nothing to keep in step and no reason to pay for it. The
// metric column still pins on the left — plain CSS sticky,
// which works inside the horizontal scroller.
//
// The key matters: both orientations root at a <div>, so
// without distinct keys Preact diffs one into the other and
// reuses the DOM nodes — carrying over the inline column
@@ -1431,9 +1440,12 @@ export function UTCIForecast() {
// behind, which is what knocks the columns out of
// alignment when you flip back.
if (tableRotated) return html`
<div key="table-rotated" class=${`utci-table-wrap is-rotated${showUnits ? '' : ' no-units'}`}>
<div class="utci-rotated-scroll" ref=${rotatedScrollRef}>
<table class="utci-table utci-table-rotated">
<div key="table-rotated" class=${`utci-table-wrap is-rotated${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span>
<div class="utci-tbody-scroll" ref=${bodyScrollRef}>
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
<thead>
<tr>
<th class="rot-corner col-info-th" scope="col"
@@ -1467,9 +1479,15 @@ export function UTCIForecast() {
<${Fragment} key=${d.key}>
${newGroup && html`
<tr class="grp-label-row grp-label-row--rotated">
<th class=${`grp-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup" colspan=${tableRows.length + 1}>
<span class="grp-label-pin">${GROUP_LABELS[newGroup]}</span>
<th class=${`grp-label rot-group-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup">
${GROUP_LABELS[newGroup]}
</th>
${/* Real cells per hour rather than one spanning cell: a
colspan leaves nothing sitting in the "now" column, so
the brass bracket running down it breaks at every group
divider. These also let the label pin like a metric name. */
tableRows.map(r => html`
<td key=${r.iso} class=${`grp-label-fill grp-${newGroup} ${hourFlags(r).isNow ? 'is-now' : ''}`.trim()}></td>`)}
</tr>`}
<tr class="rot-metric-row">
<th scope="row"
+1 -4
View File
@@ -732,8 +732,6 @@ export function useAppState() {
const bodyScrollRef = useRef(null);
const bodyTableRef = useRef(null);
const tableWrapRef = useRef(null);
// The rotated orientation's single scroll pane (scrolls in both axes).
const rotatedScrollRef = useRef(null);
// ── 7. COLUMN POPUP + TABLE SCROLL ───────────────────────────────────
const {
@@ -753,7 +751,6 @@ export function useAppState() {
handleBodyScroll,
} = useTableScroll({
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef,
rotatedScrollRef,
forecast, visibleCols, selectedDay, skinType, vehicleType,
indoorMode, indoorManaged, showDecimals, showUnits,
// The rotated table is a single table with CSS-only sticky edges, so
@@ -910,7 +907,7 @@ export function useAppState() {
forecastView, setForecastView,
// table refs
headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef, rotatedScrollRef,
bodyScrollRef, bodyTableRef, tableWrapRef,
// column popup
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
+21 -25
View File
@@ -28,7 +28,6 @@ export function useTableScroll({
headTableRef,
bodyTableRef,
bodyScrollRef,
rotatedScrollRef,
headTrackRef,
tableWrapRef,
forecast,
@@ -40,11 +39,10 @@ export function useTableScroll({
indoorManaged,
showDecimals,
showUnits,
// When the table is rotated it renders as ONE table whose sticky header
// row and sticky metric column are handled entirely in CSS. There is no
// second table to keep in step, so the width-sync and scroll-indicator
// effects short-circuit — but drag-to-scroll still applies, just to the
// rotated pane (which scrolls in both axes) instead of the body table.
// Rotated only changes what the head/body tables CONTAIN (hours across
// the top instead of metrics), not the structure — both orientations
// are a sticky header table over a horizontally scrolling body table.
// It is a dependency purely so the widths re-sync on the flip.
rotated = false,
}) {
// --- SCROLL INDICATORS ---------------------------------------------
@@ -60,27 +58,20 @@ export function useTableScroll({
// --- DRAG-TO-SCROLL ------------------------------------------------
useEffect(() => {
// Whichever element is actually the scroller in the current
// orientation. Rotated, it scrolls vertically too, so the drag
// follows both axes.
const el = rotated ? (rotatedScrollRef && rotatedScrollRef.current) : bodyScrollRef.current;
const el = bodyScrollRef.current;
if (!el) return;
let isDown = false;
let startX = 0;
let startY = 0;
let startScroll = 0;
let startScrollTop = 0;
const onMouseDown = (e) => {
// Only act on clicks that land inside the scroller
// Only act on clicks that land inside the body scroller
if (!el.contains(e.target)) return;
if (e.button !== 0) return;
if (e.target.closest('button, a, input, select')) return;
isDown = true;
startX = e.clientX;
startY = e.clientY;
startScroll = el.scrollLeft;
startScrollTop = el.scrollTop;
el.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
@@ -89,7 +80,6 @@ export function useTableScroll({
if (!isDown) return;
const dx = e.clientX - startX;
el.scrollLeft = startScroll - dx;
if (rotated) el.scrollTop = startScrollTop - (e.clientY - startY);
};
const onMouseUp = () => {
if (!isDown) return;
@@ -118,13 +108,6 @@ export function useTableScroll({
// Update indicators after layout sync (columns may have changed width)
useEffect(() => {
if (rotated) {
// Nothing is scrolling under our control — clear the fades so they
// don't linger over the rotated table.
setTableCanScrollLeft(false);
setTableCanScrollRight(false);
return;
}
updateTableScrollIndicators();
}, [forecast, visibleCols, selectedDay, rotated]);
@@ -151,12 +134,25 @@ export function useTableScroll({
// "shrink-to-fit then distribute" strategy. See the comments inside
// sync() for the algorithm.
useLayoutEffect(() => {
if (rotated) return;
const sync = () => {
const headTable = headTableRef.current;
const bodyTable = bodyTableRef.current;
const bodyScroll = bodyScrollRef.current;
if (!headTable || !bodyTable || !bodyScroll) return;
if (!bodyTable || !bodyScroll) return;
// Rotated renders a SINGLE table, so there is no head table and
// nothing to keep in step. The scroll-fade still needs to know how
// wide the pinned column is, though — and it can't be assumed from
// CSS, because auto layout widens that column to fit the longest
// metric name. Measure it, then stop.
if (!headTable) {
const firstCell = bodyTable.querySelector('tbody tr > *');
if (firstCell && tableWrapRef && tableWrapRef.current) {
const w = Math.round(firstCell.getBoundingClientRect().width);
tableWrapRef.current.style.setProperty('--hour-col-w', `${w}px`);
}
return;
}
const bodyRow = bodyTable.querySelector('tbody tr');
const headRow = headTable.querySelector('thead tr:last-child');
if (!bodyRow || !headRow) return;