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
|
||||
* 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)
|
||||
* .modal-host → PrimeVue Dialog handles centering + padding internally
|
||||
* .modal (border/bg/shadow/radius) → :pt root class overrides
|
||||
@@ -51,10 +60,11 @@
|
||||
* 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.
|
||||
* 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 Icon from '@/components/Icon.vue'
|
||||
|
||||
@@ -84,6 +94,35 @@ const visible = computed<boolean>({
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -92,28 +131,14 @@ const visible = computed<boolean>({
|
||||
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' },
|
||||
}"
|
||||
:pt="dialogPt"
|
||||
>
|
||||
<!-- 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"
|
||||
:id="titleId"
|
||||
class="text-[17px] font-bold tracking-tight leading-tight m-0"
|
||||
>
|
||||
{{ props.title }}
|
||||
|
||||
@@ -124,13 +124,25 @@ describe('AppDialog', () => {
|
||||
|
||||
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)
|
||||
// The sub element is the .mt-1 div under the header — assert it actually exists
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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 })
|
||||
|
||||
// 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')
|
||||
const openEvents = wrapper.emitted('update:open')
|
||||
const closeEvents = wrapper.emitted('close')
|
||||
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0]).toEqual([false])
|
||||
expect(openEvents).toBeTruthy()
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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 closeBtn = wrapper.find('button[aria-label="Close"]')
|
||||
@@ -181,10 +201,13 @@ describe('AppDialog', () => {
|
||||
await closeBtn.trigger('click')
|
||||
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(emitted![0]).toEqual([false])
|
||||
expect(openEvents).toBeTruthy()
|
||||
expect(openEvents![0]).toEqual([false])
|
||||
expect(closeEvents).toBeTruthy()
|
||||
expect(closeEvents![0]).toEqual([])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user