From dace65b7502e12ae078769aec85ce8c1be00a05e Mon Sep 17 00:00:00 2001 From: fraxle Date: Sun, 9 Aug 2026 09:47:57 +0100 Subject: [PATCH] 5.0.2 Fix rotated table layout issues --- assets/css/table.css | 145 ++++++++++++++++++------------ assets/js/app.js | 56 ++++++++---- assets/js/hooks/useAppState.js | 5 +- assets/js/hooks/useTableScroll.js | 46 +++++----- 4 files changed, 149 insertions(+), 103 deletions(-) diff --git a/assets/css/table.css b/assets/css/table.css index ce51a9a..cccb8eb 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -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; + } } diff --git a/assets/js/app.js b/assets/js/app.js index 9afa82e..664b52e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 + ${forecastView === 'table' && html` + `} - ${forecastView === 'table' && html` - `} ${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
, 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` -
-
- +
+ + + +
+
- + ${/* 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` + `)} `}
${newGroup && html`
- ${GROUP_LABELS[newGroup]} + + ${GROUP_LABELS[newGroup]}
{ - // 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;