From 6db929fe42444a1bebbb1e4bd6fbf519f23594a5 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 08:37:50 +0000 Subject: [PATCH] =?UTF-8?q?Tab=20layout:=20on=20phones=20the=20round=20?= =?UTF-8?q?=E2=98=B0=20rides=20above=20a=20map's=20legend=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A map's phone legend (LegendMobile) announces its sheet with the composed 'dxmaps:legend-sheet' event, dispatched from the sheet element. The layout measures that element itself and lifts the circle to 8px above the sheet's top edge: collapsed or open, wherever the map ends (Simple Research's map stops 102px above the viewport, with its grid below), so the two never overlap. It re-measures on each event, on the sheet's own resize (a ResizeObserver, so the box is read after layout), on the pane's scroll and on window resize, coalesced to a frame with a 100ms backup for a page that gets no frames. Only a sheet on screen counts: another tab's pane (hidden or inert), a scrolled-away map, or state 'gone' leave the circle where it is today. With no event at all the placement is unchanged. --map-fab-clearance rises with the circle, and --map-fab-sheet-inset:0 is published only while a sheet lifts it, so that sheet can take the full width. The circle moves by transform (no layout property). Both CSS copies change identically. Measured with a stand-in sheet planted in a plugin's shadow root (it announces after its box is laid out, as the real sheet does), at 390 in en and ar, twice each: collapsed, open, after a scroll, another tab's sheet ignored, gone back to today. All pass. --- library/layouts/VerticalTabsLayout.vue | 96 ++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 398a428ca..5da3ed6b7 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -103,8 +103,8 @@ sit NEXT TO the circle rather than on top of it (12px inset + 44px button + 8px gap). A consumer that hardcoded 64px would silently overlap the day the circle resizes; reading it means the circle's geometry is stated once, here, by the component that owns it. --> -
+
@@ -332,7 +332,7 @@ const VTL_SHADOW_CSS = ` content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in. 44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home indicator and a notch in landscape. */ -.vtl-fab { position:absolute; z-index:15; left:calc(12px + env(safe-area-inset-left)); bottom:calc(12px + env(safe-area-inset-bottom)); width:44px; height:44px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; } +.vtl-fab { position:absolute; z-index:15; left:calc(12px + env(safe-area-inset-left)); bottom:calc(12px + env(safe-area-inset-bottom)); transform:translateY(calc(-1 * var(--vtl-fab-lift, 0px))); transition:transform .2s ease; width:44px; height:44px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; } .vtl-fab:hover { background:#d1d4cd; } .vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; } .vtl-menu:hover { background:#d1d4cd; } @@ -621,7 +621,9 @@ export default defineComponent({ // railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly. narrow: false, _mql: null, _onMedia: null, activatedKeys: {}, hostState: {}, hostError: {}, _hostSlots: {}, _hostMounted: {}, - hostEls: {} + hostEls: {}, + // sheetLift: px the circle rises to clear the VISIBLE tab's phone legend sheet (0 = none, today's place). + sheetLift: 0 } }, computed: { @@ -634,6 +636,16 @@ export default defineComponent({ * test for it. */ fabShown() { return this.narrow && !this.header && !this.navOpen }, + // THE CIRCLE RIDES ABOVE A PHONE LEGEND SHEET. A map's LegendMobile announces its sheet with the composed + // 'dxmaps:legend-sheet' event, dispatched FROM the sheet element; the layout measures that element itself, + // so the circle sits 8px above the sheet's TOP wherever the map ends (Simple Research's map stops 102px above + // the viewport, with the grid below) and in both states, collapsed or open: the two never overlap. The + // measurement is redone on the pane's scroll and on resize. No visible sheet (another tab's, a scrolled-away + // map, a map without one, an older build): 0, exactly today's placement. + // While the circle rides above a sheet, that sheet may take the full width: --map-fab-sheet-inset:0 tells it so + // (LegendMobile's start inset is var(--map-fab-sheet-inset, var(--map-fab-beside))). Unset otherwise, so a + // sheet the circle is NOT lifted over keeps its room beside it. + fabLift() { return this.sheetLift }, /** * The element the back-to-top arrow scrolls. .vtl-host is the shell's scroller * (overflow-y:auto) and there is one PER TAB, so this follows `active` rather than binding a @@ -672,7 +684,7 @@ export default defineComponent({ // then keep alive). Non-element tabs are ignored here (the host renders those in #default). active: { immediate: true, - handler(key) { this.activateElement(key) } + handler(key) { this.activateElement(key); this.$nextTick(() => this.recomputeLift()) } } }, mounted() { @@ -694,6 +706,15 @@ export default defineComponent({ // The immediate `active` watcher may have run before the pane div existed; re-run for the current // active key now that the DOM is up, so an element-tab active at first render mounts. this.$nextTick(() => this.activateElement(this.active)) + this._onSheet = (e) => this.onLegendSheet(e) + // On the root ELEMENT by ref: $el is the first node of the template, which is not the root div when the + // template starts with a comment (dev builds keep comments). + this.$refs.vtlRoot?.addEventListener('dxmaps:legend-sheet', this._onSheet) + // The pane scrolls the map (and its sheet) under the circle: a capture-phase listener sees the scroll of + // any scroller in this tree. Resize moves the map's bottom too. + this._onLiftMove = () => { if (this._sheets && this._sheets.size) this.scheduleLift() } + this.$refs.vtlRoot?.addEventListener('scroll', this._onLiftMove, { capture: true, passive: true }) + window.addEventListener('resize', this._onLiftMove, { passive: true }) }, beforeUnmount() { try { @@ -703,9 +724,72 @@ export default defineComponent({ } } catch { /* ignore */ } this._mql = null; this._onMedia = null + if (this._onSheet) this.$refs.vtlRoot?.removeEventListener('dxmaps:legend-sheet', this._onSheet) + if (this._onLiftMove) { this.$refs.vtlRoot?.removeEventListener('scroll', this._onLiftMove, { capture: true }); window.removeEventListener('resize', this._onLiftMove) } + if (this._liftRaf) cancelAnimationFrame(this._liftRaf) + if (this._liftTimer) clearTimeout(this._liftTimer) + this._sheetRO?.disconnect(); this._sheetRO = null + this._onSheet = null; this._onLiftMove = null; this._sheets = null }, methods: { registerTab(key, el) { if (el) this._tabEls[key] = el }, + // One entry per announcing sheet (its own element, from the composed path), so several mounted tabs can + // each announce; only the one on screen moves the circle. state 'gone' (unmount, desktop width) drops it. + onLegendSheet(e) { + const origin = (e.composedPath && e.composedPath()[0]) || e.target + const d = (e && e.detail) || {} + if (!this._sheets) this._sheets = new Set() + // The layout also watches each announced sheet's own size: a ResizeObserver fires after layout, so an + // open/close or a drag re-measures from the box as drawn, whatever the timing of the event itself. + if (!this._sheetRO && typeof ResizeObserver === 'function') this._sheetRO = new ResizeObserver(() => this.scheduleLift()) + if (d.state === 'gone') { this._sheets.delete(origin); this._sheetRO?.unobserve(origin) } + else if (!this._sheets.has(origin)) { this._sheets.add(origin); this._sheetRO?.observe(origin) } + // Measure now AND after the next frame: a sheet may announce a state change before its own DOM update and + // layout have happened, so a synchronous read alone can see the previous box. + this.recomputeLift() + this.scheduleLift() + }, + // Coalesced to one measurement per frame (scroll and resize fire far more often than that). A 100ms timer + // backs the frame up: a page that is not the visible tab gets no animation frames at all. + scheduleLift() { + if (this._liftRaf || this._liftTimer) return + const run = () => { + if (this._liftRaf) cancelAnimationFrame(this._liftRaf) + if (this._liftTimer) clearTimeout(this._liftTimer) + this._liftRaf = 0; this._liftTimer = 0 + this.recomputeLift() + } + this._liftRaf = requestAnimationFrame(run) + this._liftTimer = setTimeout(run, 100) + }, + recomputeLift() { + const root = this.$refs.vtlRoot + let lift = 0 + if (root && this._sheets && this._sheets.size) { + const box = root.getBoundingClientRect() + // The circle's RESTING bottom offset (12px + the safe area, as its CSS resolves it): the lift is measured + // from where the circle already sits, so the gap to the sheet is exactly 8px. + const fab = root.querySelector('.vtl-fab') + const rest = fab ? (parseFloat(getComputedStyle(fab).bottom) || 0) : 12 + for (const el of this._sheets) { + if (!el || !el.isConnected) { this._sheets.delete(el); this._sheetRO?.unobserve(el); continue } + if (!this.isShown(el)) continue + const q = el.getBoundingClientRect() + // on screen inside the layout's box: its top edge is what the circle must clear + if (q.height > 0 && q.top < box.bottom && q.bottom > box.top) lift = Math.max(lift, Math.round(box.bottom - rest - q.top + 8)) + } + } + this.sheetLift = lift + }, + // On screen = rendered and not inside a hidden, inert or display:none pane (the gallery hides inactive + // panes with v-show + hidden; another host may use inert), walking out through shadow roots. + isShown(el) { + if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false + for (let n = el; n; n = n.parentNode || n.host) { + if (n.nodeType === 1 && (n.hasAttribute('hidden') || n.hasAttribute('inert'))) return false + } + return true + }, // The rail toggle. On a narrow screen the rail is an off-canvas drawer, so the toggle closes the // drawer (navOpen). On desktop it collapses/expands the slim icon rail (railCollapsed, persisted // in component data). Reading matchMedia at click time keeps one toggle correct in both modes. @@ -904,7 +988,7 @@ export default defineComponent({ content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in. 44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home indicator and a notch in landscape. */ -.vtl-fab { position:absolute; z-index:15; left:calc(12px + env(safe-area-inset-left)); bottom:calc(12px + env(safe-area-inset-bottom)); width:44px; height:44px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; } +.vtl-fab { position:absolute; z-index:15; left:calc(12px + env(safe-area-inset-left)); bottom:calc(12px + env(safe-area-inset-bottom)); transform:translateY(calc(-1 * var(--vtl-fab-lift, 0px))); transition:transform .2s ease; width:44px; height:44px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; } .vtl-fab:hover { background:#d1d4cd; } .vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; } .vtl-menu:hover { background:#d1d4cd; }