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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@

All notable changes to this project will be documented in this file.

## Unreleased

### 🐛 Fixed bugs
* fix(a11y): do not auto-open the dropdown on search input focus (WCAG 3.2.1 *On Focus*). Keyboard users still open via Space/Enter/ArrowDown/ArrowUp; mouse users still open by clicking.
* fix(a11y): hide the decorative open-indicator button from the accessibility tree (`aria-hidden="true"`, no `aria-labelledby`/`aria-controls`/`aria-expanded`). WCAG 4.1.2 *Name, Role, Value*.

### ⚠️ Behavior changes
* Focusing the combobox no longer opens the dropdown. Consumers that relied on this side-effect should open the dropdown explicitly (e.g. via `ref.open = true`) or migrate to a keyboard/click gesture.
* The open-indicator button no longer exposes its listbox state to assistive technology. Any code querying those ARIA attributes on `.vs__open-indicator-button` will need to update.

## [4.0.0](https://github.com/nextcloud-libraries/vue-select/compare/v3.26.0...v4.0.0) (2026-04-01)

### ⚠️ Breaking Changes
Expand Down
11 changes: 5 additions & 6 deletions src/components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@
class="v-select"
:class="stateClasses">
<slot name="header" v-bind="scope.header" />
<div ref="toggle" class="vs__dropdown-toggle">

Check warning on line 9 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

'toggle' is defined as ref, but never used
<div
ref="selectedOptions"
class="vs__selected-options"
@mousedown="toggleDropdown">
<slot
v-for="(option, index) in selectedValue"
name="selected-option-container"

Check warning on line 16 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

Slot name "selected-option-container" is not camelCase
:option="normalizeOptionForSlot(option)"
:deselect="deselect"
:multiple="multiple"
:disabled="disabled">
<span :key="getOptionKey(option)" class="vs__selected">
<slot
name="selected-option"

Check warning on line 23 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

Slot name "selected-option" is not camelCase
v-bind="normalizeOptionForSlot(option)">
{{ getOptionLabel(option) }}
</slot>
Expand All @@ -47,7 +47,7 @@
</slot>
</div>

<div ref="actions" class="vs__actions">

Check warning on line 50 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

'actions' is defined as ref, but never used
<button
v-show="showClearButton"
ref="clearButton"
Expand All @@ -60,18 +60,16 @@
<component :is="childComponents.Deselect" />
</button>

<!-- tabindex -1 is used to remove it from the tab sequence as tabbing to the input combobox opens the dropdown -->
<!-- Decorative: keyboard users reach the combobox input directly, which already exposes the open/close state. -->
<button
v-if="!noDrop"
ref="openIndicatorButton"

Check warning on line 66 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

'openIndicatorButton' is defined as ref, but never used
class="vs__open-indicator-button"
type="button"
tabindex="-1"
:aria-labelledby="`vs-${uid}__listbox`"
:aria-controls="`vs-${uid}__listbox`"
:aria-expanded="dropdownOpen.toString()"
aria-hidden="true"
@mousedown="toggleDropdown">
<slot name="open-indicator" v-bind="scope.openIndicator">

Check warning on line 72 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

Slot name "open-indicator" is not camelCase
<component
:is="childComponents.OpenIndicator"
v-bind="scope.openIndicator.attributes" />
Expand All @@ -89,7 +87,7 @@
<ul
v-if="dropdownOpen"
:id="`vs-${uid}__listbox`"
ref="dropdownMenu"

Check warning on line 90 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

'dropdownMenu' is defined as ref, but never used
:key="`vs-${uid}__listbox`"
v-append-to-body
class="vs__dropdown-menu"
Expand All @@ -99,7 +97,7 @@
tabindex="-1"
@mousedown.prevent="onMousedown"
@mouseup="onMouseUp">
<slot name="list-header" v-bind="scope.listHeader" />

Check warning on line 100 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

Slot name "list-header" is not camelCase
<li
v-for="(option, index) in filteredOptions"
:id="`vs-${uid}__option-${index}`"
Expand All @@ -122,11 +120,11 @@
</slot>
</li>
<li v-if="filteredOptions.length === 0" class="vs__no-options">
<slot name="no-options" v-bind="scope.noOptions">

Check warning on line 123 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

Slot name "no-options" is not camelCase
Sorry, no matching options.
</slot>
</li>
<slot name="list-footer" v-bind="scope.listFooter" />

Check warning on line 127 in src/components/Select.vue

View workflow job for this annotation

GitHub Actions / eslint

Slot name "list-footer" is not camelCase
</ul>
<ul
v-else
Expand Down Expand Up @@ -1494,13 +1492,14 @@
},

/**
* Open the dropdown on focus.
* Do NOT open the dropdown here: auto-opening on focus violates
* WCAG 3.2.1 (On Focus). Keyboard users open via
* Space/Enter/ArrowDown/ArrowUp; mouse users click.
*
* @fires {search:focus}
* @return {void}
*/
onSearchFocus() {
this.open = true
this.$emit('search:focus')
},

Expand Down
36 changes: 36 additions & 0 deletions tests/unit/Accessibility.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ describe('Open Indicator', () => {
expect(button.attributes('tabindex')).toEqual('-1')
})

it('is hidden from the accessibility tree via aria-hidden', () => {
const Select = mountDefault()
const button = Select.get({ ref: 'openIndicatorButton' })

expect(button.attributes('aria-hidden')).toEqual('true')
})

it('exposes no aria-labelledby, aria-controls, or aria-expanded so screen readers ignore it', () => {
const Select = mountDefault()
const button = Select.get({ ref: 'openIndicatorButton' })

expect(button.attributes('aria-labelledby')).toBeUndefined()
expect(button.attributes('aria-controls')).toBeUndefined()
expect(button.attributes('aria-expanded')).toBeUndefined()
})

it('toggle with mouse', async () => {
const Select = mountDefault()
const button = Select.get({ ref: 'openIndicatorButton' })
Expand All @@ -93,6 +109,26 @@ describe('Open Indicator', () => {
})
})

describe('Focus behavior', () => {
it('focusing the search input does not auto-open the dropdown (WCAG 3.2.1)', async () => {
const Select = mountDefault()

Select.vm.onSearchFocus()
await nextTick()

expect(Select.vm.open).toEqual(false)
})

it('still emits the search:focus event when the search input is focused', async () => {
const Select = mountDefault()

Select.vm.onSearchFocus()
await nextTick()

expect(Select.emitted('search:focus')).toBeTruthy()
})
})

describe('Option List', () => {
it('multiselectable attribute should not be present by default', async () => {
const Select = mountDefault()
Expand Down
10 changes: 0 additions & 10 deletions tests/unit/Dropdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,6 @@ describe('Toggling Dropdown', () => {
expect(spy).toHaveBeenCalled()
})

it('will open the dropdown and emit the search:focus event from onSearchFocus', () => {
spy = vi.spyOn(VueSelect.methods, 'onSearchFocus')
const Select = selectWithProps()

Select.vm.onSearchFocus()

expect(Select.vm.open).toEqual(true)
expect(spy).toHaveBeenCalled()
})

it('will close the dropdown on escape, if search is empty', () => {
const Select = selectWithProps()

Expand Down
4 changes: 2 additions & 2 deletions tests/unit/TypeAhead.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('Moving the Typeahead Pointer', () => {
},
})

Select.get('input').trigger('focus')
Select.vm.open = true
await nextTick()

expect(Select.vm.typeAheadPointer).toEqual(2)
Expand All @@ -74,7 +74,7 @@ describe('Moving the Typeahead Pointer', () => {
},
})

Select.get('input').trigger('focus')
Select.vm.open = true
await nextTick()

expect(Select.vm.typeAheadPointer).toEqual(2)
Expand Down
Loading