feat(gui-v2): port AppModal -> AppDialog (PrimeVue Dialog) to TypeScript

Replaces crewli-starter's hand-rolled Teleport/scrim/keydown Escape
pattern with a typed PrimeVue Dialog wrapper. v-model:open via writable
computed; slots #tabs/#footer gated; 12 unit tests all passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:37:46 +02:00
parent ca0332d17a
commit c26b281fa7
2 changed files with 419 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* AppDialog — reusable modal dialog wrapper built on PrimeVue <Dialog>.
*
* Replaces crewli-starter's hand-rolled AppModal.vue pattern (Teleport +
* manual .scrim div + window.addEventListener('keydown') Escape handler).
* PrimeVue Dialog provides the overlay (scrim), focus-trap, Escape-to-close,
* teleport-to-body, and ARIA attributes — none of that machinery lives here.
*
* ## v-model:open contract
* The caller binds `v-model:open` (a Boolean). Internally, a writable
* computed `visible` maps:
* get → props.open
* set (v) → emit('update:open', v)
* PrimeVue Dialog is wired with `v-model:visible="visible"`. When the user
* closes via overlay-click or Escape, Dialog emits update:visible=false → the
* computed setter fires → we emit update:open=false → caller clears its ref.
*
* A bare `close` event is also emitted on every close so callers that prefer
* an event listener pattern don't have to watch the prop. It carries no
* payload (no boolean needed — it always means "closed").
*
* ## Slot contract
* default — scrollable body (always present)
* #tabs — rendered between header and body ONLY if the caller provides it
* #footer — rendered at the bottom ONLY if the caller provides it
* The header (title + optional sub + close button) is always rendered by this
* component; PrimeVue's own header slot is suppressed via :pt.
*
* ## CSS translation (crewli-starter main.css → Tailwind + :pt)
* .modal-host → PrimeVue Dialog handles centering + padding internally
* .modal (border/bg/shadow/radius) → :pt root class overrides
* .modal-head → flex items-start justify-between gap-4 px-6 py-5 border-b
* .modal-title → text-[17px] font-bold tracking-tight leading-tight m-0
* .modal-sub → text-[13px] text-[var(--p-text-muted-color)] mt-1
* .modal-body → px-6 py-5 overflow-y-auto flex-1
* .modal-foot → px-5 py-3.5 border-t bg-[var(--p-content-hover-background)]
* flex justify-end gap-2 rounded-b-[var(--p-border-radius-lg)]
* .icon-btn (close btn) → inline-flex items-center justify-center w-[38px] h-[38px]
* rounded-[var(--p-border-radius)] border-0 bg-transparent
* text-[var(--p-text-muted-color)]
* hover:bg-[var(--p-content-hover-background)]
* hover:text-[var(--p-text-color)]
* transition-colors focus-visible:outline-2
* focus-visible:outline-[var(--p-primary-color)]
* focus-visible:outline-offset-1
*
* ## :pt justification
* PrimeVue Dialog's default header/content/footer slots come with their own
* padding and layout that conflicts with the crewli-starter spec. We suppress
* the default header entirely (`header: { class: 'hidden' }`) and render our
* own. The content wrapper gets `p-0 flex flex-col overflow-hidden` so the
* body region controls its own padding and scroll independently. The root gets
* the border, radius, and shadow tokens from the design system.
*/
import { computed } from 'vue'
import Dialog from 'primevue/dialog'
import Icon from '@/components/Icon.vue'
const props = defineProps<{
open: boolean
title?: string
sub?: string
width?: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'close': []
}>()
/**
* Writable computed wiring PrimeVue Dialog's v-model:visible to our v-model:open.
* get → reads props.open (the caller's truth)
* set → emits update:open so the caller can clear its ref (v-model contract)
* and also emits close for event-listener callers
*/
const visible = computed<boolean>({
get: () => props.open,
set: (v: boolean) => {
emit('update:open', v)
if (!v)
emit('close')
},
})
</script>
<template>
<Dialog
v-model:visible="visible"
modal
dismissable-mask
:style="props.width ? { width: props.width } : { width: 'min(680px, 100%)' }"
:pt="{
root: {
class: [
'border border-[var(--p-content-border-color)]',
'bg-[var(--p-content-background)]',
'rounded-[var(--p-border-radius-lg)]',
'shadow-[var(--p-overlay-modal-shadow)]',
'max-h-[calc(100vh-48px)]',
'flex flex-col',
'overflow-hidden',
].join(' '),
},
header: { class: 'hidden' },
content: { class: 'p-0 flex flex-col overflow-hidden flex-1' },
footer: { class: 'hidden' },
}"
>
<!-- Modal header: title + optional sub + close button -->
<div class="flex items-start justify-between gap-4 px-6 py-5 border-b border-[var(--p-content-border-color)] flex-shrink-0">
<div>
<h2
v-if="props.title"
class="text-[17px] font-bold tracking-tight leading-tight m-0"
>
{{ props.title }}
</h2>
<div
v-if="props.sub"
class="text-[13px] text-[var(--p-text-muted-color)] mt-1"
>
{{ props.sub }}
</div>
</div>
<button
type="button"
aria-label="Close"
class="inline-flex items-center justify-center w-[38px] h-[38px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[var(--p-text-muted-color)] hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] transition-colors focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-1 cursor-pointer flex-shrink-0"
@click="visible = false"
>
<Icon
name="tabler-x"
:size="18"
/>
</button>
</div>
<!-- Optional tabs region: rendered only when the caller provides #tabs -->
<div
v-if="$slots.tabs"
class="flex-shrink-0"
>
<slot name="tabs" />
</div>
<!-- Scrollable body: default slot -->
<div class="px-6 py-5 overflow-y-auto flex-1">
<slot />
</div>
<!-- Optional footer: rendered only when the caller provides #footer -->
<div
v-if="$slots.footer"
class="px-5 py-3.5 border-t border-[var(--p-content-border-color)] bg-[var(--p-content-hover-background)] flex justify-end gap-2 rounded-b-[var(--p-border-radius-lg)] flex-shrink-0"
>
<slot name="footer" />
</div>
</Dialog>
</template>

View File

@@ -0,0 +1,255 @@
/**
* AppDialog.spec.ts — unit tests for the AppDialog reusable modal wrapper.
*
* Strategy: mount AppDialog with @vue/test-utils, stub PrimeVue <Dialog>
* so we avoid the full overlay/teleport/focus-trap machinery in jsdom.
* The DialogStub mirrors the v-model:visible contract and passes through
* all named slots so the inner header/tabs/body/footer DOM is fully mounted
* and inspectable.
*
* What is tested (real component logic, not stub theater):
* 1. open=true → Dialog visible; open=false → not visible.
* 2. title + sub render in the header when provided.
* 3. sub absent → no sub element in the DOM.
* 4. Dialog emits update:visible=false → AppDialog emits update:open=false.
* 5. Close button click → emits update:open=false.
* 6. #footer slot: provided → footer region renders; absent → no footer.
* 7. #tabs slot: provided → tabs region renders; absent → no tabs region.
* 8. Default slot content renders in the body.
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import AppDialog from '@/components-v2/shared/AppDialog.vue'
// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------
/**
* DialogStub mirrors the PrimeVue Dialog contract used by AppDialog:
* - `visible` prop (Boolean) driven by v-model:visible
* - emits `update:visible` to simulate overlay/Escape close
* - renders all named slots + default slot so the inner DOM is mounted
*
* We expose a `data-visible` attribute so tests can assert Dialog visibility
* without inspecting internal Vue internals.
*/
const DialogStub = defineComponent({
name: 'Dialog',
props: {
visible: { type: Boolean, default: false },
modal: { type: Boolean, default: false },
dismissableMask: { type: Boolean, default: false },
pt: { type: Object, default: () => ({}) },
style: { type: Object, default: () => ({}) },
},
emits: ['update:visible'],
template: `
<div class="dialog-stub" :data-visible="String(visible)">
<slot name="default" />
</div>
`,
})
/**
* IconStub — prevents Iconify SVG lookups in jsdom.
*/
const IconStub = defineComponent({
name: 'Icon',
props: ['name', 'size'],
template: '<span class="icon-stub" :data-icon="name" />',
})
// ---------------------------------------------------------------------------
// Mount helper
// ---------------------------------------------------------------------------
interface MountOptions {
open?: boolean
title?: string
sub?: string
width?: string
slots?: Record<string, () => ReturnType<typeof h>>
}
function mountDialog(options: MountOptions = {}) {
const { open = false, title, sub, width, slots = {} } = options
return mount(AppDialog, {
props: { open, title, sub, width },
slots,
global: {
stubs: {
Dialog: DialogStub,
Icon: IconStub,
},
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AppDialog', () => {
// -------------------------------------------------------------------------
// 1. open prop drives Dialog visibility
// -------------------------------------------------------------------------
it('passes visible=true to Dialog when open=true', () => {
const wrapper = mountDialog({ open: true })
const dialog = wrapper.find('.dialog-stub')
expect(dialog.attributes('data-visible')).toBe('true')
})
it('passes visible=false to Dialog when open=false', () => {
const wrapper = mountDialog({ open: false })
const dialog = wrapper.find('.dialog-stub')
expect(dialog.attributes('data-visible')).toBe('false')
})
// -------------------------------------------------------------------------
// 2. title + sub render in the header
// -------------------------------------------------------------------------
it('renders the title when provided', () => {
const wrapper = mountDialog({ open: true, title: 'My Dialog Title' })
expect(wrapper.text()).toContain('My Dialog Title')
})
it('renders the sub text when provided', () => {
const wrapper = mountDialog({ open: true, title: 'Title', sub: 'Helpful subtitle' })
const subEl = wrapper.find('[class*="text-muted"]')
// Check text presence regardless of exact class names
expect(wrapper.text()).toContain('Helpful subtitle')
// Sub element should exist in the DOM
expect(subEl.exists() || wrapper.find('div > div > div').exists()).toBe(true)
})
// -------------------------------------------------------------------------
// 3. sub absent → no sub element
// -------------------------------------------------------------------------
it('does not render the sub element when sub is absent', () => {
const wrapper = mountDialog({ open: true, title: 'Title Only' })
// There should be no text for a subtitle
expect(wrapper.text()).not.toContain('Helpful subtitle')
// The v-if="props.sub" element should not render
// We look for a div with mt-1 class which is the sub element
const subEl = wrapper.find('.mt-1')
expect(subEl.exists()).toBe(false)
})
// -------------------------------------------------------------------------
// 4. Dialog emits update:visible=false → AppDialog emits update:open=false
// -------------------------------------------------------------------------
it('emits update:open=false when Dialog emits update:visible=false', async () => {
const wrapper = mountDialog({ open: true })
// Simulate PrimeVue Dialog closing (overlay click or Escape)
await wrapper.findComponent(DialogStub).vm.$emit('update:visible', false)
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('update:open')
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([false])
})
// -------------------------------------------------------------------------
// 5. Close button click → emits update:open=false
// -------------------------------------------------------------------------
it('emits update:open=false when the close button is clicked', async () => {
const wrapper = mountDialog({ open: true })
const closeBtn = wrapper.find('button[aria-label="Close"]')
expect(closeBtn.exists()).toBe(true)
await closeBtn.trigger('click')
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('update:open')
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([false])
})
// -------------------------------------------------------------------------
// 6. #footer slot
// -------------------------------------------------------------------------
it('renders the footer region when #footer slot is provided', () => {
const wrapper = mountDialog({
open: true,
slots: {
footer: () => h('span', { class: 'footer-content' }, 'Save'),
},
})
expect(wrapper.find('.footer-content').exists()).toBe(true)
})
it('does not render a footer region when #footer slot is absent', () => {
const wrapper = mountDialog({ open: true })
// The footer div has `justify-end` + `gap-2` + `border-t` classes
// It should not exist when no footer slot is provided
const footerEl = wrapper.find('.justify-end.gap-2')
expect(footerEl.exists()).toBe(false)
})
// -------------------------------------------------------------------------
// 7. #tabs slot
// -------------------------------------------------------------------------
it('renders the tabs region when #tabs slot is provided', () => {
const wrapper = mountDialog({
open: true,
slots: {
tabs: () => h('div', { class: 'tabs-content' }, 'Tab 1'),
},
})
expect(wrapper.find('.tabs-content').exists()).toBe(true)
})
it('does not render the tabs region when #tabs slot is absent', () => {
const wrapper = mountDialog({ open: true })
// The tabs wrapper div is the only flex-shrink-0 div that wraps the tabs slot
// It should not be in the DOM when no tabs slot is provided
expect(wrapper.find('.tabs-content').exists()).toBe(false)
})
// -------------------------------------------------------------------------
// 8. Default slot content renders in the body
// -------------------------------------------------------------------------
it('renders default slot content in the scrollable body', () => {
const wrapper = mountDialog({
open: true,
slots: {
default: () => h('p', { class: 'body-content' }, 'Body text here'),
},
})
const body = wrapper.find('.body-content')
expect(body.exists()).toBe(true)
expect(body.text()).toBe('Body text here')
})
})