Adds composables/drawerRegistry.ts (boundary-safe register-by-call map: register/resolve, zero static component imports — composables zone may not import components, RFC-WS-GUI-REDESIGN AD-G5). Extends useRightDrawer with resolveDrawerComponent (thin facade, prior API/tests preserved). RightDrawer.vue: PrimeVue <Drawer position=right>, v-model:visible via a writable computed ↔ useRightDrawer isOpen/close; title/flush read from the open() props object (A4); dynamic <component :is> via resolveDrawerComponent with a graceful empty state on null; #actions header slot retained. 18 unit/component tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
163 lines
5.9 KiB
Vue
163 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* RightDrawer — the global right-side drawer shell driven entirely by
|
|
* `useRightDrawer()` / `useShellUiStore().drawer`. No open/title/flush props
|
|
* (Decision A4): the caller passes chrome keys (`title`, `flush`) inside the
|
|
* `props` object when calling `useRightDrawer().open('Name', { title, flush, ...bodyProps })`.
|
|
*
|
|
* PrimeVue <Drawer position="right"> provides the scrim, escape-key dismiss,
|
|
* overlay-click close, and focus-trap. The writable computed `drawerVisible`
|
|
* wires v-model:visible to useRightDrawer().isOpen / .close() without
|
|
* mutating the store ref directly.
|
|
*
|
|
* Body component lifecycle:
|
|
* - `component.value` (string | null) is resolved via resolveDrawerComponent().
|
|
* - Unknown / unregistered names → null → graceful empty state (no crash).
|
|
* This is the expected state in the foundation scope; real drawer-body
|
|
* components register themselves via registerDrawerComponent() from their
|
|
* own feature zone (components-v2, pages-v2, etc.) at mount time.
|
|
* - Chrome keys `title` and `flush` are stripped from `bodyProps` and NOT
|
|
* forwarded to the body component — they are consumed at the shell level only.
|
|
*
|
|
* #actions slot:
|
|
* Parent layouts may inject header action buttons (e.g. an edit icon) via the
|
|
* named `#actions` slot. In the store-driven model this slot is typically unused,
|
|
* but it is kept for composed layouts that wrap <RightDrawer> and need to add
|
|
* persistent header controls without duplicating the chrome.
|
|
*
|
|
* CSS translation (crewli-starter main.css → Tailwind):
|
|
* .drawer → handled by PrimeVue Drawer + :pt passthrough
|
|
* .drawer-head → flex items-center gap-3 px-4 py-3 border-b ...
|
|
* .drawer-head .title → flex-1 font-medium text-sm truncate
|
|
* .drawer-head .actions → ms-auto flex items-center gap-1
|
|
* .drawer-body (flush) → p-0 (edge-to-edge)
|
|
* .drawer-body (normal)→ p-4 overflow-y-auto
|
|
* .icon-btn → rounded-md p-1 hover:bg-[var(--p-content-hover-background)]
|
|
* transition-colors flex items-center justify-center
|
|
*/
|
|
|
|
import { computed } from 'vue'
|
|
import Drawer from 'primevue/drawer'
|
|
import Icon from '@/components/Icon.vue'
|
|
import { useRightDrawer } from '@/composables/useRightDrawer'
|
|
|
|
const { isOpen, component, props, close, resolveDrawerComponent } = useRightDrawer()
|
|
|
|
/**
|
|
* Writable computed so PrimeVue Drawer can use v-model:visible without
|
|
* directly mutating the Pinia store ref.
|
|
*/
|
|
const drawerVisible = computed<boolean>({
|
|
get: () => isOpen.value,
|
|
set: (v: boolean) => {
|
|
if (!v)
|
|
close()
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Chrome keys read from drawerProps WITHOUT using `any`.
|
|
* `typeof` narrowing instead of a cast keeps TypeScript strict.
|
|
*/
|
|
const title = computed(() =>
|
|
typeof props.value.title === 'string' ? props.value.title : '',
|
|
)
|
|
|
|
const flush = computed(() => props.value.flush === true)
|
|
|
|
/**
|
|
* Body props: `props.value` minus the chrome keys `title` and `flush`.
|
|
* These are NOT forwarded to the body component — they are consumed by the
|
|
* drawer shell. All other keys are passed through as-is.
|
|
*
|
|
* Key-filter approach avoids unused-variable linting errors from destructuring
|
|
* (the project's varsIgnorePattern only allows purely-underscore names).
|
|
*/
|
|
const CHROME_KEYS = new Set(['title', 'flush'])
|
|
|
|
const bodyProps = computed(() =>
|
|
Object.fromEntries(
|
|
Object.entries(props.value).filter(([k]) => !CHROME_KEYS.has(k)),
|
|
),
|
|
)
|
|
|
|
/**
|
|
* Resolved body component from the registry. Null when the component name is
|
|
* unregistered (expected in foundation scope) — the template renders a graceful
|
|
* empty state in that case.
|
|
*/
|
|
const Body = computed(() => resolveDrawerComponent(component.value))
|
|
</script>
|
|
|
|
<template>
|
|
<Drawer
|
|
v-model:visible="drawerVisible"
|
|
position="right"
|
|
:pt="{
|
|
/*
|
|
* Strip PrimeVue's default header so we render our own with the
|
|
* close button, title, and #actions slot in a single controlled row.
|
|
* content: remove default padding so body region controls its own spacing.
|
|
*/
|
|
header: { class: 'hidden' },
|
|
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
|
|
}"
|
|
>
|
|
<!-- Drawer header: close button + title + optional #actions slot -->
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--p-content-border-color)] flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
aria-label="Close"
|
|
class="rounded-md p-1 hover:bg-[var(--p-content-hover-background)] transition-colors flex items-center justify-center"
|
|
@click="close"
|
|
>
|
|
<Icon
|
|
name="tabler-x"
|
|
:size="18"
|
|
/>
|
|
</button>
|
|
|
|
<div class="flex-1 font-medium text-sm truncate">
|
|
{{ title }}
|
|
</div>
|
|
|
|
<!--
|
|
#actions slot — parent layouts may inject header action buttons here.
|
|
In the pure store-driven model this slot is typically empty.
|
|
-->
|
|
<div class="ms-auto flex items-center gap-1">
|
|
<slot name="actions" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drawer body: dynamic component or graceful empty state -->
|
|
<div
|
|
class="flex-1 overflow-y-auto"
|
|
:class="flush ? 'p-0' : 'p-4'"
|
|
>
|
|
<!--
|
|
Body is rendered only when a registered component is resolved.
|
|
When Body.value is null (unknown / not-yet-registered name), we render
|
|
a minimal empty state — this must NOT crash on null (resolveDrawerComponent
|
|
guarantees null for unknown names).
|
|
-->
|
|
<component
|
|
:is="Body"
|
|
v-if="Body !== null"
|
|
v-bind="bodyProps"
|
|
/>
|
|
|
|
<!--
|
|
Graceful empty state — shown when the component name is unregistered.
|
|
Expected in foundation scope; body components self-register at mount.
|
|
-->
|
|
<p
|
|
v-else
|
|
class="text-sm text-[var(--p-text-muted-color)]"
|
|
>
|
|
Geen inhoud
|
|
</p>
|
|
</div>
|
|
</Drawer>
|
|
</template>
|