fix(gui-v2): wire AppDialog accessible name + cover close/width in tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,15 @@
|
|||||||
* The header (title + optional sub + close button) is always rendered by this
|
* The header (title + optional sub + close button) is always rendered by this
|
||||||
* component; PrimeVue's own header slot is suppressed via :pt.
|
* component; PrimeVue's own header slot is suppressed via :pt.
|
||||||
*
|
*
|
||||||
|
* ## Accessible name
|
||||||
|
* Because PrimeVue's built-in header is suppressed (`header: { class: 'hidden' }`),
|
||||||
|
* its default `aria-labelledby` would point to an empty hidden element, leaving
|
||||||
|
* the dialog without an accessible name. We give the custom <h2> a stable
|
||||||
|
* useId() id and override the root `aria-labelledby` to it via :pt.root (which
|
||||||
|
* mergeProps-overrides PrimeVue's internal value). When no title is provided
|
||||||
|
* the override is omitted (a titleless dialog is an edge the caller is expected
|
||||||
|
* to name via its own content; documented limitation).
|
||||||
|
*
|
||||||
* ## CSS translation (crewli-starter main.css → Tailwind + :pt)
|
* ## CSS translation (crewli-starter main.css → Tailwind + :pt)
|
||||||
* .modal-host → PrimeVue Dialog handles centering + padding internally
|
* .modal-host → PrimeVue Dialog handles centering + padding internally
|
||||||
* .modal (border/bg/shadow/radius) → :pt root class overrides
|
* .modal (border/bg/shadow/radius) → :pt root class overrides
|
||||||
@@ -51,10 +60,11 @@
|
|||||||
* the default header entirely (`header: { class: 'hidden' }`) and render our
|
* the default header entirely (`header: { class: 'hidden' }`) and render our
|
||||||
* own. The content wrapper gets `p-0 flex flex-col overflow-hidden` so the
|
* 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
|
* body region controls its own padding and scroll independently. The root gets
|
||||||
* the border, radius, and shadow tokens from the design system.
|
* the border, radius, and shadow tokens from the design system, plus the
|
||||||
|
* aria-labelledby override described above.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed, useId } from 'vue'
|
||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
|
|
||||||
@@ -84,6 +94,35 @@ const visible = computed<boolean>({
|
|||||||
emit('close')
|
emit('close')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable, document-unique id for the custom <h2>, used to wire the dialog's
|
||||||
|
* accessible name (see "Accessible name" note above).
|
||||||
|
*/
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passthrough object. Extracted from the template so the root section can
|
||||||
|
* conditionally carry the aria-labelledby override (only meaningful when a
|
||||||
|
* title — and therefore the <h2 :id="titleId"> — is rendered).
|
||||||
|
*/
|
||||||
|
const dialogPt = computed(() => ({
|
||||||
|
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(' '),
|
||||||
|
...(props.title ? { 'aria-labelledby': titleId } : {}),
|
||||||
|
},
|
||||||
|
header: { class: 'hidden' },
|
||||||
|
content: { class: 'p-0 flex flex-col overflow-hidden flex-1' },
|
||||||
|
footer: { class: 'hidden' },
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -92,28 +131,14 @@ const visible = computed<boolean>({
|
|||||||
modal
|
modal
|
||||||
dismissable-mask
|
dismissable-mask
|
||||||
:style="props.width ? { width: props.width } : { width: 'min(680px, 100%)' }"
|
:style="props.width ? { width: props.width } : { width: 'min(680px, 100%)' }"
|
||||||
:pt="{
|
:pt="dialogPt"
|
||||||
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 -->
|
<!-- 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 class="flex items-start justify-between gap-4 px-6 py-5 border-b border-[var(--p-content-border-color)] flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
v-if="props.title"
|
v-if="props.title"
|
||||||
|
:id="titleId"
|
||||||
class="text-[17px] font-bold tracking-tight leading-tight m-0"
|
class="text-[17px] font-bold tracking-tight leading-tight m-0"
|
||||||
>
|
>
|
||||||
{{ props.title }}
|
{{ props.title }}
|
||||||
|
|||||||
@@ -124,13 +124,25 @@ describe('AppDialog', () => {
|
|||||||
|
|
||||||
it('renders the sub text when provided', () => {
|
it('renders the sub text when provided', () => {
|
||||||
const wrapper = mountDialog({ open: true, title: 'Title', sub: 'Helpful subtitle' })
|
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')
|
expect(wrapper.text()).toContain('Helpful subtitle')
|
||||||
|
|
||||||
// Sub element should exist in the DOM
|
// The sub element is the .mt-1 div under the header — assert it actually exists
|
||||||
expect(subEl.exists() || wrapper.find('div > div > div').exists()).toBe(true)
|
expect(wrapper.find('.mt-1').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the width prop through to the Dialog style', () => {
|
||||||
|
const wrapper = mountDialog({ open: true, width: '400px' })
|
||||||
|
const dialog = wrapper.findComponent(DialogStub)
|
||||||
|
|
||||||
|
expect(dialog.props('style')).toEqual({ width: '400px' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the default width when no width prop is given', () => {
|
||||||
|
const wrapper = mountDialog({ open: true })
|
||||||
|
const dialog = wrapper.findComponent(DialogStub)
|
||||||
|
|
||||||
|
expect(dialog.props('style')).toEqual({ width: 'min(680px, 100%)' })
|
||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -154,24 +166,32 @@ describe('AppDialog', () => {
|
|||||||
// 4. Dialog emits update:visible=false → AppDialog emits update:open=false
|
// 4. Dialog emits update:visible=false → AppDialog emits update:open=false
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('emits update:open=false when Dialog emits update:visible=false', async () => {
|
it('emits update:open=false AND close when Dialog emits update:visible=false', async () => {
|
||||||
const wrapper = mountDialog({ open: true })
|
const wrapper = mountDialog({ open: true })
|
||||||
|
|
||||||
// Simulate PrimeVue Dialog closing (overlay click or Escape)
|
|
||||||
await wrapper.findComponent(DialogStub).vm.$emit('update:visible', false)
|
await wrapper.findComponent(DialogStub).vm.$emit('update:visible', false)
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
const emitted = wrapper.emitted('update:open')
|
const openEvents = wrapper.emitted('update:open')
|
||||||
|
const closeEvents = wrapper.emitted('close')
|
||||||
|
|
||||||
expect(emitted).toBeTruthy()
|
expect(openEvents).toBeTruthy()
|
||||||
expect(emitted![0]).toEqual([false])
|
expect(openEvents![0]).toEqual([false])
|
||||||
|
expect(closeEvents).toBeTruthy()
|
||||||
|
expect(closeEvents![0]).toEqual([])
|
||||||
|
|
||||||
|
// Contract: update:open fires before close (JSDoc-documented order)
|
||||||
|
const order = wrapper.emitted()
|
||||||
|
const keys = Object.keys(order)
|
||||||
|
|
||||||
|
expect(keys.indexOf('update:open')).toBeLessThan(keys.indexOf('close'))
|
||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// 5. Close button click → emits update:open=false
|
// 5. Close button click → emits update:open=false
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('emits update:open=false when the close button is clicked', async () => {
|
it('emits update:open=false AND close when the close button is clicked', async () => {
|
||||||
const wrapper = mountDialog({ open: true })
|
const wrapper = mountDialog({ open: true })
|
||||||
|
|
||||||
const closeBtn = wrapper.find('button[aria-label="Close"]')
|
const closeBtn = wrapper.find('button[aria-label="Close"]')
|
||||||
@@ -181,10 +201,13 @@ describe('AppDialog', () => {
|
|||||||
await closeBtn.trigger('click')
|
await closeBtn.trigger('click')
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
const emitted = wrapper.emitted('update:open')
|
const openEvents = wrapper.emitted('update:open')
|
||||||
|
const closeEvents = wrapper.emitted('close')
|
||||||
|
|
||||||
expect(emitted).toBeTruthy()
|
expect(openEvents).toBeTruthy()
|
||||||
expect(emitted![0]).toEqual([false])
|
expect(openEvents![0]).toEqual([false])
|
||||||
|
expect(closeEvents).toBeTruthy()
|
||||||
|
expect(closeEvents![0]).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user