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:
164
apps/app/src/components-v2/shared/AppDialog.vue
Normal file
164
apps/app/src/components-v2/shared/AppDialog.vue
Normal 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>
|
||||
255
apps/app/src/components-v2/shared/__tests__/AppDialog.spec.ts
Normal file
255
apps/app/src/components-v2/shared/__tests__/AppDialog.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user