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:
2026-05-16 21:53:04 +02:00
parent c26b281fa7
commit 3685797e18
2 changed files with 79 additions and 31 deletions

View File

@@ -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 }}

View File

@@ -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([])
})
// -------------------------------------------------------------------------