Files
archy/neode-ui/src/views/dashboard/dashboardViewWrappers.ts
T

125 lines
5.7 KiB
TypeScript

// Per-route view wrapper components for DashboardRouterView (02-02 fix).
//
// WHY THIS FILE EXISTS — the visual contract that broke at the Task 3
// checkpoint and must never break again:
//
// dashboard-styles.css scopes EVERY route transition as a compound selector,
// e.g. `.slide-up-enter-active.view-wrapper`, `.depth-forward-enter-from.view-wrapper`.
// That means Vue's transition classes and the `view-wrapper` class MUST land on
// the SAME element, and `.view-wrapper` (position: absolute; inset: 0) must be
// the direct child of `.perspective-container` so the 3D perspective chain for
// the depth transitions is intact and nothing clips the slide movement.
// `.view-wrapper` must also stay OUTSIDE the padded/scrollable per-route
// wrapper divs — putting `view-wrapper` on the view's own root inside the
// padded wrapper pins the view over the page padding (broken margins).
//
// So the transitioning, keyed element has to be `div.view-wrapper` containing
// the per-route wrapper shape — exactly the pre-02-02 Dashboard.vue markup.
// To ALSO get KeepAlive instance caching, that keyed div must be a cached
// component's own root. These statically-defined wrapper components provide
// that root. Unlike the async route components (RESEARCH A1 / vuejs/core
// #11764), statically-defined components have reliable `name`s, so KeepAlive's
// `include` can gate cache membership by wrapper name.
import { defineComponent, h, type Component, type PropType } from 'vue'
import { KEEP_ALIVE_PATHS, shouldKeepAlive } from './keepAliveRoutes'
/** Name prefix for cacheable wrappers — what KeepAlive's `include` matches. */
export const KEEP_WRAP_PREFIX = 'KeepWrap:'
/** Routes that render edge-to-edge (no page padding). */
export function isFullBleedPath(path: string): boolean {
return path === '/dashboard/chat' || path === '/dashboard/mesh'
}
/** Component names KeepAlive should instance-cache, derived from
* KEEP_ALIVE_PATHS so route classification stays single-sourced. */
export function keepAliveIncludeNames(): string[] {
return Array.from(KEEP_ALIVE_PATHS, (path) => KEEP_WRAP_PREFIX + path)
}
/** One wrapper component per kept-alive path and per full-bleed path; a single
* shared component for every other (default-shape, uncached) route. Keying the
* cache this way keeps it bounded — detail routes with unbounded param paths
* (`/dashboard/marketplace/:id`, …) all reuse the shared default wrapper, and
* Transition/KeepAlive still distinguish them via `:key="route.path"` on the
* wrapper vnode. */
const DEFAULT_WRAPPER_KEY = 'default'
const wrapperCache = new Map<string, Component>()
function makeWrapper(path: string, cacheable: boolean): Component {
const fullBleed = isFullBleedPath(path)
return defineComponent({
// Cacheable wrappers get the include-matched name; everything else shares
// a name that never matches `include`, so it is never instance-cached.
name: cacheable ? KEEP_WRAP_PREFIX + path : 'DashboardViewWrapper',
props: {
mobileTabPaddingTop: { type: Number as PropType<number | null>, default: null },
needsMobileBackButtonSpace: { type: Boolean, default: false },
},
setup(props, { slots }) {
// Renders the pre-02-02 Dashboard.vue markup byte-for-byte:
// div.view-wrapper <- transition surface, absolute inset-0
// div (full-bleed OR padded shape) <- page margins / scroll container
// <routed view> [+ spacer]
return () => {
const paddingStyle = props.mobileTabPaddingTop
? { paddingTop: `${props.mobileTabPaddingTop + 16}px` }
: undefined
const inner = fullBleed
? h(
'div',
{
class: [
'h-full',
path === '/dashboard/mesh'
? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel'
: '',
props.mobileTabPaddingTop ? 'overflow-y-auto' : '',
'mobile-safe-top',
],
style: paddingStyle,
},
slots.default?.(),
)
: h(
'div',
{
class: [
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
props.needsMobileBackButtonSpace ? 'mobile-scroll-pad-back' : 'mobile-scroll-pad',
],
style: paddingStyle,
},
[
slots.default?.(),
// Bottom spacer — scroll clearance on all pages
h('div', { class: 'shrink-0 h-6 md:h-12', 'aria-hidden': 'true' }),
],
)
return h('div', { class: 'view-wrapper' }, [inner])
}
},
})
}
/** Memoized wrapper lookup — stable component identity per route so unrelated
* re-renders never remount the current view. */
export function wrapperFor(path: string): Component {
const cacheable = shouldKeepAlive({ path })
// The `isFullBleedPath(path)` half of this check is currently unreachable
// (IN-02): isFullBleedPath() only returns true for /dashboard/chat and
// /dashboard/mesh, both always in KEEP_ALIVE_PATHS today, so `cacheable`
// alone already covers every path that reaches this branch. Kept as
// deliberate defense against a future TAB_ORDER/WITHHELD_FROM_CACHE change
// that could withhold a full-bleed path from the cache — not dead code to
// delete.
const key = cacheable || isFullBleedPath(path) ? path : DEFAULT_WRAPPER_KEY
let wrapper = wrapperCache.get(key)
if (!wrapper) {
wrapper = makeWrapper(path, cacheable)
wrapperCache.set(key, wrapper)
}
return wrapper
}