Skip to content
Merged
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
1 change: 1 addition & 0 deletions lib/components/SDropdownSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defineProps<{
:options="section.options"
:query="section.query"
:active="section.active"
:option-id-prefix="section.optionIdPrefix"
:on-click="section.onClick"
/>
<SDropdownSectionDateRange
Expand Down
10 changes: 9 additions & 1 deletion lib/components/SDropdownSectionFilter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const props = defineProps<{
selected: MaybeRef<DropdownSectionFilterSelectedValue>
options: MaybeRef<DropdownSectionFilterOption[]>
active?: any
optionIdPrefix?: string
onClick?(value: any): void
}>()

Expand Down Expand Up @@ -80,6 +81,10 @@ function handleClick(option: DropdownSectionFilterOption, value: any) {
option.onClick && option.onClick(value)
props.onClick && props.onClick(value)
}

function getOptionId(index: number) {
return props.optionIdPrefix ? `${props.optionIdPrefix}-${index}` : undefined
}
</script>

<template>
Expand All @@ -89,10 +94,13 @@ function handleClick(option: DropdownSectionFilterOption, value: any) {
</div>

<ul v-if="filteredOptions.length" class="list">
<li v-for="option in filteredOptions" :key="option.label" class="item">
<li v-for="(option, index) in filteredOptions" :key="option.label" class="item">
<button
:id="getOptionId(index)"
class="button"
:class="{ active: isActive(option.value), focused: isFocused(option.value) }"
:role="optionIdPrefix ? 'option' : undefined"
:aria-selected="optionIdPrefix ? isActive(option.value) : undefined"
tabindex="0"
@keyup.up.prevent="focusPrev"
@keyup.down.prevent="focusNext"
Expand Down
23 changes: 21 additions & 2 deletions lib/components/SInputDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import IconCaretUp from '~icons/ph/caret-up'
import IconX from '~icons/ph/x'
import Fuse from 'fuse.js'
import xor from 'lodash-es/xor'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, useId, watch } from 'vue'
import { type DropdownSectionFilter, useManualDropdownPosition } from '../composables/Dropdown'
import { useFlyout } from '../composables/Flyout'
import { useTrans } from '../composables/Lang'
Expand Down Expand Up @@ -67,6 +67,8 @@ const inlineInput = ref<HTMLInputElement>()
const inlineQuery = ref('')
const inlineActiveIndex = ref(-1)

const dropdownId = useId()

const { isOpen, open } = useFlyout(container)
const { inset, update: updatePosition } = useManualDropdownPosition(container, () => props.position)

Expand Down Expand Up @@ -111,6 +113,7 @@ const dropdownOptions = computed<DropdownSectionFilter[]>(() => [{
selected: model.value,
options: isInlineSearch.value ? inlineFilteredOptions.value : props.options,
active: inlineActiveOption.value?.value,
optionIdPrefix: isInlineSearch.value ? `${dropdownId}-option` : undefined,
onClick: handleSelect
}])

Expand All @@ -136,6 +139,15 @@ const removable = computed(() => {
return !!props.nullable
})

const ariaActiveDescendant = computed(() => {
if (!isInlineSearch.value || !inlineActiveOption.value) {
return undefined
}

// Generate an ID for the active option based on its index
return `${dropdownId}-option-${inlineActiveIndex.value}`
})

watch(inlineQuery, (value) => {
if (!isInlineSearch.value) {
return
Expand Down Expand Up @@ -363,6 +375,9 @@ function focusInlineInput() {
class="box"
:class="{ 'inline-search': isInlineSearch }"
:role="isInlineSearch ? 'combobox' : 'button'"
:aria-expanded="isInlineSearch ? isOpen : undefined"
:aria-controls="isInlineSearch ? dropdownId : undefined"
:aria-activedescendant="isInlineSearch ? ariaActiveDescendant : undefined"
:tabindex="isInlineSearch ? undefined : 0"
@click="handleBoxClick"
@keydown.down.prevent
Expand Down Expand Up @@ -455,7 +470,11 @@ function focusInlineInput() {
</div>

<div v-if="isOpen" class="dropdown" :style="inset">
<div class="dropdown-content">
<div
:id="isInlineSearch ? dropdownId : undefined"
class="dropdown-content"
:role="isInlineSearch ? 'listbox' : undefined"
>
<SDropdown :sections="dropdownOptions" />
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions lib/composables/Dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface DropdownSectionFilter extends DropdownSectionBase {
options: MaybeRef<DropdownSectionFilterOption[]>
query?: string
active?: DropdownSectionFilterSelectedValue
optionIdPrefix?: string
onClick?(value: any): void
}

Expand Down
131 changes: 131 additions & 0 deletions tests/components/SInputDropdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils'
import SInputDropdown from 'sefirot/components/SInputDropdown.vue'
import { nextTick } from 'vue'

describe('components/SInputDropdown', () => {
describe('ARIA attributes for inline search', () => {
it('should add ARIA attributes when search="inline"', async () => {
const wrapper = mount(SInputDropdown, {
props: {
search: 'inline',
options: [
{ label: 'Option 1', value: 1 },
{ label: 'Option 2', value: 2 },
{ label: 'Option 3', value: 3 }
],
modelValue: []
}
})

const box = wrapper.find('.box')

// Should have combobox role
expect(box.attributes('role')).toBe('combobox')

// Should have aria-expanded (initially false since dropdown is closed)
expect(box.attributes('aria-expanded')).toBe('false')

// Should have aria-controls
expect(box.attributes('aria-controls')).toBeDefined()

// aria-activedescendant should not be set when no option is active
expect(box.attributes('aria-activedescendant')).toBeUndefined()
})

it('should update aria-expanded when dropdown opens', async () => {
const wrapper = mount(SInputDropdown, {
props: {
search: 'inline',
options: [
{ label: 'Option 1', value: 1 },
{ label: 'Option 2', value: 2 }
],
modelValue: []
}
})

const box = wrapper.find('.box')
expect(box.attributes('aria-expanded')).toBe('false')

// Click to open dropdown
await box.trigger('click')
await nextTick()

expect(box.attributes('aria-expanded')).toBe('true')
})

it('should set aria-activedescendant when navigating options', async () => {
const wrapper = mount(SInputDropdown, {
props: {
search: 'inline',
options: [
{ label: 'Option 1', value: 1 },
{ label: 'Option 2', value: 2 }
],
modelValue: []
}
})

// Open dropdown first
await wrapper.find('.box').trigger('click')
await nextTick()

const input = wrapper.find('.inline-input')

// Type to trigger filtering
await input.setValue('Option')
await nextTick()

const box = wrapper.find('.box')

// aria-activedescendant should be set when an option is active
const ariaActiveDescendant = box.attributes('aria-activedescendant')
expect(ariaActiveDescendant).toBeDefined()
expect(ariaActiveDescendant).toMatch(/option-0$/)
})

it('should add role="listbox" to dropdown when search="inline"', async () => {
const wrapper = mount(SInputDropdown, {
props: {
search: 'inline',
options: [
{ label: 'Option 1', value: 1 },
{ label: 'Option 2', value: 2 }
],
modelValue: []
}
})

// Open dropdown
await wrapper.find('.box').trigger('click')
await nextTick()

const dropdownContent = wrapper.find('.dropdown-content')
expect(dropdownContent.attributes('role')).toBe('listbox')
expect(dropdownContent.attributes('id')).toBeDefined()
})

it('should not add ARIA attributes when search is not "inline"', async () => {
const wrapper = mount(SInputDropdown, {
props: {
search: true,
options: [
{ label: 'Option 1', value: 1 },
{ label: 'Option 2', value: 2 }
],
modelValue: null
}
})

const box = wrapper.find('.box')

// Should have button role, not combobox
expect(box.attributes('role')).toBe('button')

// Should not have ARIA attributes
expect(box.attributes('aria-expanded')).toBeUndefined()
expect(box.attributes('aria-controls')).toBeUndefined()
expect(box.attributes('aria-activedescendant')).toBeUndefined()
})
})
})