Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-dialog-dangling-aria-idrefs-608.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@vuetify/v0": patch
---

fix(Dialog,AlertDialog): omit `aria-labelledby`/`aria-describedby` when title/description are absent (#631)

`DialogContent` and `AlertDialogContent` unconditionally emitted `aria-labelledby`/`aria-describedby` pointing at ids even when no `Title`/`Description` sub-component was rendered, producing a dangling IDREF — invalid per the ARIA spec and liable to make screen readers announce empty text or skip the dialog's accessible name. Both attributes are now gated on the referenced sub-component actually being mounted.
8 changes: 4 additions & 4 deletions packages/0/src/components/AlertDialog/AlertDialogContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
'id': string
'role': 'alertdialog'
'aria-modal': 'true'
'aria-labelledby': string
'aria-describedby': string
'aria-labelledby': string | undefined
'aria-describedby': string | undefined
'style': { zIndex: number }
'onCancel': (e: Event) => void
'onClose': (e: Event) => void
Expand Down Expand Up @@ -162,8 +162,8 @@
'id': context.id,
'role': 'alertdialog',
'aria-modal': 'true',
'aria-labelledby': context.titleId,
'aria-describedby': context.descriptionId,
'aria-labelledby': context.titlePresent.value ? context.titleId : undefined,
'aria-describedby': context.descriptionPresent.value ? context.descriptionId : undefined,
'style': { zIndex: ticket.zIndex.value },
'onCancel': onCancel,
'onClose': onClose,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import { useAlertDialogContext } from './AlertDialogRoot.vue'

// Utilities
import { toRef } from 'vue'
import { onMounted, onUnmounted, toRef } from 'vue'

defineOptions({ name: 'AlertDialogDescription' })

Expand All @@ -49,6 +49,13 @@

const context = useAlertDialogContext(namespace)

onMounted(() => {
context.descriptionPresent.value = true
})
onUnmounted(() => {
context.descriptionPresent.value = false
})

const slotProps = toRef((): AlertDialogDescriptionSlotProps => ({
attrs: {
id: context.descriptionId,
Expand Down
6 changes: 6 additions & 0 deletions packages/0/src/components/AlertDialog/AlertDialogRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
id: string
titleId: string
descriptionId: string
titlePresent: ShallowRef<boolean>
descriptionPresent: ShallowRef<boolean>
isPending: ShallowRef<boolean>
open: () => void
close: () => void
Expand Down Expand Up @@ -78,6 +80,8 @@

const isOpen = defineModel<boolean>({ default: false })
const isPending = shallowRef(false)
const titlePresent = shallowRef(false)
const descriptionPresent = shallowRef(false)

const titleId = `${id}-title`
const descriptionId = `${id}-description`
Expand All @@ -96,6 +100,8 @@
id,
titleId,
descriptionId,
titlePresent,
descriptionPresent,
isPending,
open,
close,
Expand Down
9 changes: 8 additions & 1 deletion packages/0/src/components/AlertDialog/AlertDialogTitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import { useAlertDialogContext } from './AlertDialogRoot.vue'

// Utilities
import { toRef } from 'vue'
import { onMounted, onUnmounted, toRef } from 'vue'

defineOptions({ name: 'AlertDialogTitle' })

Expand All @@ -49,6 +49,13 @@

const context = useAlertDialogContext(namespace)

onMounted(() => {
context.titlePresent.value = true
})
onUnmounted(() => {
context.titlePresent.value = false
})

const slotProps = toRef((): AlertDialogTitleSlotProps => ({
attrs: {
id: context.titleId,
Expand Down
60 changes: 58 additions & 2 deletions packages/0/src/components/AlertDialog/index.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe('alertDialog', () => {
expect(content.attributes('aria-modal')).toBe('true')
})

it('should have aria-labelledby pointing to title', () => {
it('should have aria-labelledby pointing to title', async () => {
const wrapper = mountWithStack(AlertDialog.Root, {
props: { id: 'test-alert' },
slots: {
Expand All @@ -252,11 +252,12 @@ describe('alertDialog', () => {
},
})

await nextTick()
const content = wrapper.findComponent(AlertDialog.Content as any)
expect(content.attributes('aria-labelledby')).toBe('test-alert-title')
})

it('should have aria-describedby pointing to description', () => {
it('should have aria-describedby pointing to description', async () => {
const wrapper = mountWithStack(AlertDialog.Root, {
props: { id: 'test-alert' },
slots: {
Expand All @@ -266,10 +267,65 @@ describe('alertDialog', () => {
},
})

await nextTick()
const content = wrapper.findComponent(AlertDialog.Content as any)
expect(content.attributes('aria-describedby')).toBe('test-alert-description')
})

it('should omit aria-labelledby when no title is rendered', async () => {
const wrapper = mountWithStack(AlertDialog.Root, {
props: { id: 'test-alert' },
slots: {
default: () => h(AlertDialog.Content, {}, () => 'No title'),
},
})

const content = wrapper.findComponent(AlertDialog.Content as any)
expect(content.attributes('aria-labelledby')).toBeUndefined()
})

it('should remove aria-labelledby when title is dynamically removed', async () => {
const showTitle = ref(true)

const wrapper = mountWithStack(AlertDialog.Root, {
props: { id: 'test-alert' },
slots: {
default: () => h(AlertDialog.Content, {}, () => [
showTitle.value ? h(AlertDialog.Title, {}, () => 'Title') : null,
]),
},
})

await nextTick()
const content = wrapper.findComponent(AlertDialog.Content as any)
expect(content.attributes('aria-labelledby')).toBe('test-alert-title')

showTitle.value = false
await nextTick()
expect(content.attributes('aria-labelledby')).toBeUndefined()
})

it('should remove aria-describedby when description is dynamically removed', async () => {
const showDesc = ref(true)

const wrapper = mountWithStack(AlertDialog.Root, {
props: { id: 'test-alert' },
slots: {
default: () => h(AlertDialog.Content, {}, () => [
showDesc.value ? h(AlertDialog.Description, {}, () => 'Description') : null,
]),
},
})

await nextTick()
const content = wrapper.findComponent(AlertDialog.Content as any)
expect(content.attributes('aria-describedby')).toBe('test-alert-description')

showDesc.value = false
await nextTick()
expect(content.attributes('aria-describedby')).toBeUndefined()
})

it('should call showModal when opened', async () => {
mountWithStack(AlertDialog.Root, {
props: { modelValue: true },
Expand Down
8 changes: 4 additions & 4 deletions packages/0/src/components/Dialog/DialogContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@
'id': string
'role': 'dialog'
'aria-modal': 'true'
'aria-labelledby': string
'aria-describedby': string
'aria-labelledby': string | undefined
'aria-describedby': string | undefined
'style': { zIndex: number }
'onCancel': (e: Event) => void
'onClose': (e: Event) => void
Expand Down Expand Up @@ -156,8 +156,8 @@
'id': context.id,
'role': 'dialog',
'aria-modal': 'true',
'aria-labelledby': context.titleId,
'aria-describedby': context.descriptionId,
'aria-labelledby': context.titlePresent.value ? context.titleId : undefined,
'aria-describedby': context.descriptionPresent.value ? context.descriptionId : undefined,
'style': { zIndex: ticket.zIndex.value },
'onCancel': onCancel,
'onClose': onClose,
Expand Down
9 changes: 8 additions & 1 deletion packages/0/src/components/Dialog/DialogDescription.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import { useDialogContext } from './DialogRoot.vue'

// Utilities
import { toRef } from 'vue'
import { onMounted, onUnmounted, toRef } from 'vue'

defineOptions({ name: 'DialogDescription' })

Expand All @@ -49,6 +49,13 @@

const context = useDialogContext(namespace)

onMounted(() => {
context.descriptionPresent.value = true
})
onUnmounted(() => {
context.descriptionPresent.value = false
})

const slotProps = toRef((): DialogDescriptionSlotProps => ({
attrs: {
id: context.descriptionId,
Expand Down
8 changes: 7 additions & 1 deletion packages/0/src/components/Dialog/DialogRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
id: string
titleId: string
descriptionId: string
titlePresent: ShallowRef<boolean>
descriptionPresent: ShallowRef<boolean>
open: () => void
close: () => void
}
Expand Down Expand Up @@ -52,7 +54,7 @@

// Utilities
import { useId } from '#v0/utilities'
import { toRef } from 'vue'
import { shallowRef, toRef } from 'vue'

defineOptions({ name: 'DialogRoot' })

Expand All @@ -75,6 +77,8 @@
const id = _id ?? useId()
const titleId = `${id}-title`
const descriptionId = `${id}-description`
const titlePresent = shallowRef(false)
const descriptionPresent = shallowRef(false)

function open () {
isOpen.value = true
Expand All @@ -89,6 +93,8 @@
id,
titleId,
descriptionId,
titlePresent,
descriptionPresent,
open,
close,
})
Expand Down
9 changes: 8 additions & 1 deletion packages/0/src/components/Dialog/DialogTitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import { useDialogContext } from './DialogRoot.vue'

// Utilities
import { toRef } from 'vue'
import { onMounted, onUnmounted, toRef } from 'vue'

defineOptions({ name: 'DialogTitle' })

Expand All @@ -49,6 +49,13 @@

const context = useDialogContext(namespace)

onMounted(() => {
context.titlePresent.value = true
})
onUnmounted(() => {
context.titlePresent.value = false
})

const slotProps = toRef((): DialogTitleSlotProps => ({
attrs: {
id: context.titleId,
Expand Down
55 changes: 49 additions & 6 deletions packages/0/src/components/Dialog/index.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ describe('dialog', () => {
expect(content.attributes('aria-modal')).toBe('true')
})

it('should have aria-labelledby pointing to title', () => {
it('should have aria-labelledby pointing to title', async () => {
const wrapper = mountWithStack(Dialog.Root, {
props: { id: 'test-dialog' },
slots: {
Expand All @@ -405,11 +405,12 @@ describe('dialog', () => {
},
})

await nextTick()
const content = wrapper.findComponent(Dialog.Content as any)
expect(content.attributes('aria-labelledby')).toBe('test-dialog-title')
})

it('should have aria-describedby pointing to description', () => {
it('should have aria-describedby pointing to description', async () => {
const wrapper = mountWithStack(Dialog.Root, {
props: { id: 'test-dialog' },
slots: {
Expand All @@ -419,8 +420,51 @@ describe('dialog', () => {
},
})

await nextTick()
const content = wrapper.findComponent(Dialog.Content as any)
expect(content.attributes('aria-describedby')).toBe('test-dialog-description')
})

it('should remove aria-labelledby when title is dynamically removed', async () => {
const showTitle = ref(true)

const wrapper = mountWithStack(Dialog.Root, {
props: { id: 'test-dialog' },
slots: {
default: () => h(Dialog.Content, {}, () => [
showTitle.value ? h(Dialog.Title, {}, () => 'Title') : null,
]),
},
})

await nextTick()
const content = wrapper.findComponent(Dialog.Content as any)
expect(content.attributes('aria-labelledby')).toBe('test-dialog-title')

showTitle.value = false
await nextTick()
expect(content.attributes('aria-labelledby')).toBeUndefined()
})

it('should remove aria-describedby when description is dynamically removed', async () => {
const showDesc = ref(true)

const wrapper = mountWithStack(Dialog.Root, {
props: { id: 'test-dialog' },
slots: {
default: () => h(Dialog.Content, {}, () => [
showDesc.value ? h(Dialog.Description, {}, () => 'Description') : null,
]),
},
})

await nextTick()
const content = wrapper.findComponent(Dialog.Content as any)
expect(content.attributes('aria-describedby')).toBe('test-dialog-description')

showDesc.value = false
await nextTick()
expect(content.attributes('aria-describedby')).toBeUndefined()
})
})

Expand Down Expand Up @@ -989,7 +1033,7 @@ describe('dialog', () => {
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled()
})

it('should handle missing title and description', () => {
it('should omit aria-labelledby and aria-describedby when title and description are absent', () => {
const wrapper = mountWithStack(Dialog.Root, {
props: { id: 'no-title-dialog' },
slots: {
Expand All @@ -998,9 +1042,8 @@ describe('dialog', () => {
})

const content = wrapper.findComponent(Dialog.Content as any)
// Should still have aria attributes pointing to potentially missing elements
expect(content.attributes('aria-labelledby')).toBe('no-title-dialog-title')
expect(content.attributes('aria-describedby')).toBe('no-title-dialog-description')
expect(content.attributes('aria-labelledby')).toBeUndefined()
expect(content.attributes('aria-describedby')).toBeUndefined()
})

it('should handle dialog without trigger (controlled)', async () => {
Expand Down
Loading