From 4efe3744da10950d4fe2695bdf94555978671365 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 10:05:24 +0000 Subject: [PATCH] =?UTF-8?q?Tab=20layout:=20the=20round=20=E2=98=B0=20is=20?= =?UTF-8?q?a=20small=20fixed=20corner=20button=20on=20phones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ☰ no longer rides above the legend sheet: that lift made it fly on scroll and re-animate on a tab switch. It is now 40px, 8px from the bottom-start corner of the layout (plus the safe area; bottom-right in Arabic), and never moves: no lift, no scroll tracking, no transition. It sits over the corner of the legend sheet; the collapsed sheet pads its start edge by --map-fab-beside (now 56px = 8 + 40 + 8) so its caret and title clear it. --map-fab-clearance stays 56px. The legend-sheet listener, its measurement and --map-fab-sheet-inset are removed (the sheet may keep dispatching its event). The open drawer keeps z 100000. Both CSS copies change identically. --- library/layouts/VerticalTabsLayout.vue | 106 ++++--------------------- 1 file changed, 14 insertions(+), 92 deletions(-) diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 51c8f1dcb..9489abfd6 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,10 @@ 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)); 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; } +/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not + ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its + start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */ +.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; 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; } @@ -553,7 +556,7 @@ const VTL_SHADOW_CSS = ` .vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; } .vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); } .vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); } -.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(12px + env(safe-area-inset-right)); } +.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); } .vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); } .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; } .vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:184px; } @@ -624,9 +627,7 @@ 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: {}, - // sheetLift: px the circle rises to clear the VISIBLE tab's phone legend sheet (0 = none, today's place). - sheetLift: 0 + hostEls: {} } }, computed: { @@ -639,16 +640,6 @@ 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 @@ -687,7 +678,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); this.$nextTick(() => this.recomputeLift()) } + handler(key) { this.activateElement(key) } } }, mounted() { @@ -709,15 +700,6 @@ 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 { @@ -727,72 +709,9 @@ 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. @@ -991,7 +910,10 @@ 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)); 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; } +/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not + ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its + start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */ +.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; 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; } @@ -1212,7 +1134,7 @@ export default defineComponent({ .vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; } .vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); } .vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); } -.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(12px + env(safe-area-inset-right)); } +.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); } .vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); } .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; } .vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:184px; }