183 lines
8.3 KiB
JavaScript
183 lines
8.3 KiB
JavaScript
/**
|
|
* useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
|
*
|
|
* A dashboard shell (see plugin-designs/doxa-research-dashboards/research-dashboards.vue) is a CSS
|
|
* grid whose three flexible tracks are driven by CSS custom properties:
|
|
* --facet-w (left facet-rail column width) default 248px
|
|
* --rail-w (right charts-rail column width) default 312px
|
|
* --table-h (bottom table row height) default 34% (map above it is the 1fr hero)
|
|
*
|
|
* This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge),
|
|
* clamps each to a sane min/max, writes the vars onto the shell root element as INLINE custom
|
|
* properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init,
|
|
* and offers reset() to clear the key and snap back to the exact defaults.
|
|
*
|
|
* COMPOSITION with the collapse-table fix (do NOT regress):
|
|
* The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule
|
|
* `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source
|
|
* order (same specificity) so COLLAPSE WINS over --table-h. Because we only touch the CSS var
|
|
* (never the collapsed row template), collapsing hides the table regardless of a dragged height,
|
|
* and expanding restores the user's dragged --table-h. The center map is 1fr and already
|
|
* self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize.
|
|
*
|
|
* NON-REGRESSION with the responsive slim rails:
|
|
* `.rd-shell.rd-slim-rail{ --rail-w:240px }` / `.rd-shell.rd-slim-facet{ --facet-w:196px }` are
|
|
* medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline
|
|
* vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched
|
|
* dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved.
|
|
* reset() removes the inline vars entirely, fully handing control back to the stylesheet.
|
|
*
|
|
* DESKTOP-ONLY: every drag is gated on `!isNarrow`; narrow/mobile never resizes or persists.
|
|
*
|
|
* Library-parameterizable — the profile's identity (defaults + storage key) lives in options, not
|
|
* here. Any dashboard grid exposing the same three vars can reuse this unchanged.
|
|
*
|
|
* @param {object} options
|
|
* @param {import('vue').Ref<HTMLElement|null>} options.shellRef ref holding the .rd-shell root el
|
|
* @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset)
|
|
* @param {import('vue').Ref<boolean>|boolean=} options.isNarrow desktop gate (true = mobile/narrow)
|
|
* @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout')
|
|
* @param {{facetW?:number, railW?:number, tableH?:string}=} options.defaults default track sizes
|
|
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number]}=} options.clamps
|
|
* min/max — facetW/railW in px, tableH in percent-of-shell-height
|
|
*
|
|
* @returns {{
|
|
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
|
|
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
|
|
* startTableResize:(e:PointerEvent)=>void, reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>
|
|
* }}
|
|
*/
|
|
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
|
|
|
|
const DEFAULTS = { facetW: 248, railW: 312, tableH: '34%' }
|
|
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70] }
|
|
|
|
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))
|
|
const pctNum = (v, fallback) => {
|
|
const n = parseFloat(String(v))
|
|
return Number.isFinite(n) ? n : fallback
|
|
}
|
|
|
|
export function useDashboardLayout(options = {}) {
|
|
const {
|
|
shellRef,
|
|
bus = null,
|
|
isNarrow = false,
|
|
storageKey = 'rd-dashboard-layout',
|
|
} = options
|
|
|
|
const defaults = { ...DEFAULTS, ...(options.defaults || {}) }
|
|
const clamps = { ...CLAMPS, ...(options.clamps || {}) }
|
|
|
|
const facetW = ref(defaults.facetW) // px
|
|
const railW = ref(defaults.railW) // px
|
|
const tableH = ref(defaults.tableH) // string, e.g. '34%'
|
|
const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults?
|
|
|
|
const narrow = () => !!unref(isNarrow)
|
|
|
|
// ── write / clear the three inline CSS custom properties on the shell root ──
|
|
function applyVars() {
|
|
const el = shellRef && shellRef.value
|
|
if (!el || !customized.value) return
|
|
el.style.setProperty('--facet-w', facetW.value + 'px')
|
|
el.style.setProperty('--rail-w', railW.value + 'px')
|
|
el.style.setProperty('--table-h', tableH.value)
|
|
}
|
|
function clearVars() {
|
|
const el = shellRef && shellRef.value
|
|
if (!el) return
|
|
el.style.removeProperty('--facet-w')
|
|
el.style.removeProperty('--rail-w')
|
|
el.style.removeProperty('--table-h')
|
|
}
|
|
|
|
// ── persistence ──
|
|
function persist() {
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify({
|
|
facetW: facetW.value, railW: railW.value, tableH: tableH.value,
|
|
}))
|
|
} catch (_) { /* storage blocked / SSR — layout still works this session */ }
|
|
}
|
|
function restore() {
|
|
try {
|
|
const raw = localStorage.getItem(storageKey)
|
|
if (!raw) return false
|
|
const s = JSON.parse(raw)
|
|
if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW)
|
|
if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW)
|
|
if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%'
|
|
customized.value = true
|
|
return true
|
|
} catch (_) { return false }
|
|
}
|
|
|
|
// ── reset: clear the key + snap back to the EXACT defaults (hand control to the stylesheet) ──
|
|
function reset() {
|
|
try { localStorage.removeItem(storageKey) } catch (_) { /* ignore */ }
|
|
facetW.value = defaults.facetW
|
|
railW.value = defaults.railW
|
|
tableH.value = defaults.tableH
|
|
customized.value = false
|
|
clearVars()
|
|
}
|
|
|
|
// ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ──
|
|
function startResize(axis, e) {
|
|
if (narrow()) return
|
|
const el = shellRef && shellRef.value
|
|
if (!el) return
|
|
e.preventDefault()
|
|
const rect = el.getBoundingClientRect()
|
|
const startX = e.clientX
|
|
const startFacet = facetW.value
|
|
const startRail = railW.value
|
|
const handleEl = e.currentTarget
|
|
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
|
|
|
|
function move(ev) {
|
|
if (axis === 'facet') {
|
|
// left facet edge → dragging right widens the facet column
|
|
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
|
|
} else if (axis === 'rail') {
|
|
// right rail's LEFT edge → dragging left widens the rail column
|
|
railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW))
|
|
} else if (axis === 'table') {
|
|
// map/table divider → dragging up grows the table (height measured from shell bottom)
|
|
const pct = ((rect.bottom - ev.clientY) / rect.height) * 100
|
|
tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%'
|
|
}
|
|
customized.value = true
|
|
applyVars()
|
|
}
|
|
function up(ev) {
|
|
window.removeEventListener('pointermove', move)
|
|
window.removeEventListener('pointerup', up)
|
|
window.removeEventListener('pointercancel', up)
|
|
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
|
|
persist()
|
|
}
|
|
window.addEventListener('pointermove', move)
|
|
window.addEventListener('pointerup', up)
|
|
window.addEventListener('pointercancel', up)
|
|
}
|
|
|
|
const startFacetResize = (e) => startResize('facet', e)
|
|
const startRailResize = (e) => startResize('rail', e)
|
|
const startTableResize = (e) => startResize('table', e)
|
|
|
|
// ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ──
|
|
let offReset = null
|
|
onMounted(() => {
|
|
if (restore()) applyVars()
|
|
if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset)
|
|
})
|
|
onBeforeUnmount(() => { try { offReset && offReset() } catch (_) {} })
|
|
|
|
return {
|
|
facetW, railW, tableH, customized,
|
|
startFacetResize, startRailResize, startTableResize,
|
|
startResize, reset, applyVars,
|
|
}
|
|
}
|