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-breadcrumbs-collapsed-a11y-614.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@vuetify/v0": minor
---

fix(Breadcrumbs): expose collapsed-items count and `aria-expanded` on the expander (#634)

Collapsed breadcrumb items were invisible to screen readers: the ellipsis hardcoded `aria-hidden="true"` and was non-interactive, so truncated levels were unreachable by assistive technology with no disclosure path to reveal them. `BreadcrumbsEllipsis` now accepts an `interactive` prop that turns it into a disclosure toggle exposing `aria-expanded`, a count-aware `aria-label` (e.g. "Show 3 more breadcrumbs"), and click/Enter/Space activation.
67 changes: 64 additions & 3 deletions packages/0/src/components/Breadcrumbs/BreadcrumbsEllipsis.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
* BreadcrumbsRoot as type='ellipsis'. Root's watcher controls visibility
* based on overflow state - hidden when everything fits, shown when truncating.
* Renders as a list item by default with aria-hidden.
*
* With the `interactive` prop, the ellipsis becomes a disclosure toggle that
* reveals the collapsed items: aria-hidden is dropped, and the element exposes
* aria-expanded, an aria-label announcing the hidden item count, and
* click/keyboard activation.
*/

<script lang="ts">
Expand All @@ -17,11 +22,14 @@
// Context
import { useBreadcrumbsRoot } from './BreadcrumbsRoot.vue'

// Composables
import { useLocale } from '#v0/composables/useLocale'

// Constants
import { IN_BROWSER } from '#v0/constants/globals'

// Utilities
import { onBeforeUnmount, toRef, useTemplateRef, watch } from 'vue'
import { onBeforeUnmount, shallowRef, toRef, useTemplateRef, watch } from 'vue'

// Types
import type { AtomProps } from '#v0/components/Atom'
Expand All @@ -34,6 +42,8 @@
id?: ID
/** Override ellipsis character (uses global from Root if not provided) */
ellipsis?: string
/** Make the ellipsis a disclosure toggle that reveals collapsed items */
interactive?: boolean
}

export interface BreadcrumbsEllipsisSlotProps {
Expand All @@ -43,10 +53,22 @@
ellipsis: string
/** Whether the ellipsis is currently selected (visible) */
isSelected: boolean
/** Whether collapsed items are currently revealed (interactive only) */
isExpanded: boolean
/** Number of breadcrumb items hidden by truncation */
hiddenCount: number
/** Toggle visibility of collapsed items (interactive only) */
toggle: () => void
/** Attributes to bind to the ellipsis element */
attrs: {
'aria-hidden': 'true'
'aria-hidden': 'true' | undefined
'aria-expanded': 'true' | 'false' | undefined
'aria-label': string | undefined
'role': 'button' | undefined
'tabindex': 0 | undefined
'data-selected': true | undefined
'onClick': (() => void) | undefined
'onKeydown': ((e: KeyboardEvent) => void) | undefined
}
}
</script>
Expand All @@ -64,8 +86,10 @@
namespace = 'v0:breadcrumbs',
id,
ellipsis,
interactive,
} = defineProps<BreadcrumbsEllipsisProps>()

const locale = useLocale()
const elRef = useTemplateRef('el')
const context = useBreadcrumbsRoot(namespace)

Expand Down Expand Up @@ -97,14 +121,51 @@

const resolvedEllipsis = toRef(() => ellipsis ?? context.ellipsis.value)
const isSelected = toRef(() => ticket.isSelected.value)
const isExpanded = toRef(() => context.expanded.value)

// Remember the last non-zero hidden count so the accessible name stays
// stable while the disclosure is open (hiddenCount drops to 0 on expand).
const lastHiddenCount = shallowRef(0)

watch(
() => context.hiddenCount.value,
count => {
if (count > 0) lastHiddenCount.value = count
},
{ immediate: true },
)

const hiddenCount = toRef(() => context.hiddenCount.value || lastHiddenCount.value)

function toggle () {
context.expanded.value = !context.expanded.value
}

function onKeydown (e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle()
}
}

const slotProps = toRef((): BreadcrumbsEllipsisSlotProps => ({
id: ticket.id,
ellipsis: resolvedEllipsis.value,
isSelected: isSelected.value,
isExpanded: isExpanded.value,
hiddenCount: hiddenCount.value,
toggle,
attrs: {
'aria-hidden': 'true',
'aria-hidden': interactive ? undefined : 'true',
'aria-expanded': interactive ? (isExpanded.value ? 'true' : 'false') : undefined,
'aria-label': interactive
? locale.ti('Breadcrumbs.expand', { count: hiddenCount.value }) ?? `Show ${hiddenCount.value} more breadcrumbs`
: undefined,
'role': interactive && as !== 'button' ? 'button' : undefined,
'tabindex': interactive && as !== 'button' ? 0 : undefined,
'data-selected': isSelected.value || undefined,
'onClick': interactive ? toggle : undefined,
'onKeydown': interactive && as !== 'button' ? onKeydown : undefined,
},
}))
</script>
Expand Down
4 changes: 4 additions & 0 deletions packages/0/src/components/Breadcrumbs/BreadcrumbsItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
isSelected: boolean
/** Attributes to bind to the item element */
attrs: {
'aria-hidden': 'true' | undefined
'data-selected': true | undefined
}
}
Expand Down Expand Up @@ -100,6 +101,9 @@
id: ticket.id,
isSelected: isSelected.value,
attrs: {
// Collapsed items are display:none via v-show, but renderless consumers
// control rendering themselves — keep them out of the a11y tree too.
'aria-hidden': isSelected.value ? undefined : 'true',
'data-selected': isSelected.value || undefined,
},
}))
Expand Down
26 changes: 25 additions & 1 deletion packages/0/src/components/Breadcrumbs/BreadcrumbsRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@

const isOverflowing = overflow.isOverflowing

// Disclosure state — when true, collapsed items are revealed (toggled by
// an interactive BreadcrumbsEllipsis).
const expanded = shallowRef(false)

const hiddenCount = toRef(() => {
let count = 0
for (const t of group.values()) {
if (t.type === 'item' && !t.isSelected.value) count++
}
return count
})

// Element measurement routing — first item/divider write to reserved refs,
// everything else feeds into the overflow pool.
let _firstItemIndex: number | null = null
Expand Down Expand Up @@ -156,7 +168,7 @@
}

watch(
[() => overflow.capacity.value, () => group.size],
[() => overflow.capacity.value, () => group.size, expanded],
([capacity]) => {
const all = group.values()

Expand Down Expand Up @@ -190,6 +202,16 @@
return
}

if (expanded.value) {
// Disclosure is open — reveal all collapsed content and keep the
// ellipsis visible so it can serve as the collapse toggle.
for (const t of ellipsisTickets) group.select(t.id)
for (const t of contentTickets) {
if (!t.isSelected.value) group.select(t.id)
}
return
}

for (const t of ellipsisTickets) group.select(t.id)

for (let i = 0; i < 2 && i < contentSize; i++) {
Expand Down Expand Up @@ -240,6 +262,8 @@
divider: toRef(() => divider),
ellipsis: toRef(() => ellipsis),
isOverflowing,
expanded,
hiddenCount,
ellipsisWidth,
measureElement,
})
Expand Down
Loading
Loading