Tab layout: on phones the round ☰ rides above a map's legend sheet
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.
This commit is contained in:
parent
0fc6307b19
commit
6db929fe42
1 changed files with 90 additions and 6 deletions
|
|
@ -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. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
|
||||
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(64px + env(safe-area-inset-left))' } : null">
|
||||
<div ref="vtlRoot" class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
|
||||
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom) + ' + fabLift + 'px)', '--map-fab-beside': 'calc(64px + env(safe-area-inset-left))', '--vtl-fab-lift': fabLift + 'px', ...(fabLift > 0 ? { '--map-fab-sheet-inset': '0px' } : {}) } : null">
|
||||
<header class="vtl-top" v-if="header">
|
||||
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
:aria-label="label('toggleList')">☰</button>
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue