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