diff --git a/cypress/e2e/a11y-server-search-announcements.cy.ts b/cypress/e2e/a11y-server-search-announcements.cy.ts new file mode 100644 index 0000000..0c2839a --- /dev/null +++ b/cypress/e2e/a11y-server-search-announcements.cy.ts @@ -0,0 +1,192 @@ +/** cSpell:ignore vscomp */ + +/** + * Server-search status messages must describe an interaction the user actually made. + * + * WCAG 4.1.3 Status Messages (AA) requires status changes to be announced; it does not + * license announcements for state the user cannot see. `closeDropbox()` ends with + * `setSearchValue('')`, which on a server instance schedules a fetch, so closing the + * dropdown spoke "Loading results" one `searchDelay` later and then the result count when + * the host responded - two announcements, for a dropdown that is no longer open. + * + * The local-search path guards exactly this in `announceSearchResults()`; the server path + * bypassed it. + */ + +import { mountVs, unmountVs } from '../support/mount'; + +type ServerHarness = { + /** every search value `onServerSearch` was called with, in order */ + calls: string[]; + /** hand the last-seen instance a result set, as a host's fetch callback would */ + respond: (options: Array<{ label: string; value: string }>) => void; +}; + +declare global { + interface Window { + __vsServer?: ServerHarness; + } +} + +describe('A11y: server-search announcements', () => { + const mountId = 'vs-a11y-server-search'; + + const liveRegion = () => cy.get(`#${mountId}`).find('.vscomp-live-region'); + const searchInput = () => cy.get(`#${mountId}`).find('.vscomp-search-input'); + const harness = () => cy.window().its('__vsServer'); + + /** + * `searchDelay` is deliberately long: the bug is about what happens when the dropdown is + * closed *before* the debounce elapses, so the window has to be wide enough for Cypress to + * type and close inside it. Nothing waits on the delay itself - the assertions wait on the + * fetch actually being issued. + */ + const SEARCH_DELAY = 400; + + const mount = (extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => { + const calls: string[] = []; + let respond: ServerHarness['respond'] = () => undefined; + + mountVs(win, mountId, { + options: [], + search: true, + searchDelay: SEARCH_DELAY, + onServerSearch: (searchValue: string, instance: { setServerOptions: (o: unknown[]) => void }) => { + calls.push(searchValue); + respond = (options) => instance.setServerOptions(options); + }, + ...extra, + }); + + win.__vsServer = { + calls, + respond: (options) => respond(options), + }; + }); + }; + + const results = [ + { label: 'Result 1', value: 'r1' }, + { label: 'Result 2', value: 'r2' }, + { label: 'Result 3', value: 'r3' }, + ]; + + beforeEach(() => mount()); + + afterEach(() => { + cy.window().then((win) => { + unmountVs(win, mountId); + delete win.__vsServer; + }); + }); + + context('while the dropdown is open', () => { + it('announces that a fetch is in flight, then the number of matches', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + searchInput().focus().type('Res', { delay: 0 }); + + // The spinner is a visual-only cue, so the fetch has to be announced. + liveRegion().should('have.text', 'Loading results'); + + harness().invoke('respond', results); + + liveRegion().should('have.text', '3 results available'); + }); + }); + + context('after the dropdown has been closed', () => { + it('does not announce a fetch the user did not trigger', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + searchInput().focus().type('Res', { delay: 0 }); + + // Closed inside the debounce window: the user's own fetch never goes out. + searchInput().type('{esc}'); + + // Wait on the event, not on a duration: the reset performed by closeDropbox() issues a + // fetch for the empty search value, and that is the moment the stray announcement was made. + harness().its('calls').should('deep.equal', ['']); + + liveRegion().should('have.text', ''); + }); + + it('does not announce a result count for a closed dropdown', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + searchInput().focus().type('Res', { delay: 0 }); + searchInput().type('{esc}'); + + harness().its('calls').should('deep.equal', ['']); + harness().invoke('respond', results); + + liveRegion().should('have.text', ''); + }); + + /** + * The close-time reset is not the only path here. closeDropbox() clears the search via + * setSearchValue(''), which early-returns when the box is already empty - so a user who + * deletes their query *before* closing leaves a pending fetch that the close never sees. + * That fetch is still the user's own from before the close, but by the time it fires the + * dropdown is gone, and it must not speak. + */ + it('does not announce a fetch left pending by clearing the search before closing', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + searchInput().focus().type('Res', { delay: 0 }); + + // Empty the box by hand, then close inside the debounce window: the close-time reset + // early-returns (the value is already ''), so only the backspace's own fetch remains. + searchInput().type('{selectall}{backspace}', { delay: 0 }); + searchInput().type('{esc}'); + + harness().its('calls').should('deep.equal', ['']); + + liveRegion().should('have.text', ''); + }); + }); + + context('after the dropdown has been reopened', () => { + /** + * The silence must not outlive the closed state. A close marks the pending reset silent; + * once the user reopens, a host that pushes fresh options into the visible list is + * describing something the user is looking at, and it has to be announced again. + */ + it('announces options pushed by the host while the dropdown is open again', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + searchInput().focus().type('Res', { delay: 0 }); + searchInput().type('{esc}'); + + // The close-initiated reset runs to completion, silently. + harness().its('calls').should('deep.equal', ['']); + harness().invoke('respond', results); + liveRegion().should('have.text', ''); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('have.attr', 'aria-expanded', 'true'); + + harness().invoke('respond', results); + + liveRegion().should('have.text', '3 results available'); + }); + }); + + context('on page load', () => { + /** + * showOptionsOnlyOnSearch forces a search reset during construction, which on a server + * instance schedules a fetch. That fetch spoke "Loading results" on a closed dropdown + * the user had not touched yet - `isInitialized` could not catch it, because the flag is + * already true by the time the debounce elapses. + */ + it('does not announce the fetch forced by showOptionsOnlyOnSearch', () => { + mount({ showOptionsOnlyOnSearch: true }); + + // The construction-time reset issues its fetch one searchDelay after load. + harness().its('calls').should('deep.equal', ['']); + liveRegion().should('have.text', ''); + + // Nor may the host's response to it speak - the dropdown is still closed. + harness().invoke('respond', results); + liveRegion().should('have.text', ''); + }); + }); +}); diff --git a/src/virtual-select.js b/src/virtual-select.js index cb040ed..69124fe 100644 --- a/src/virtual-select.js +++ b/src/virtual-select.js @@ -1172,6 +1172,15 @@ export class VirtualSelect { if (this.hasServerSearch) { clearTimeout(this.serverSearchTimeout); + /** + * A search the user types is never silent, whatever state a previous close left behind. + * The other half of the lifecycle lives in closeDropbox()/openDropbox(): closing marks + * the instance silent *after* its own setSearchValue('') runs, so it does not matter + * what this assignment does during the close - but it must not matter, which is why + * this is unconditional rather than `= this.isClosing`. + */ + this.isSilentServerSearch = false; + this.serverSearchTimeout = setTimeout(() => { this.serverSearch(); }, this.searchDelay); @@ -1387,6 +1396,8 @@ export class VirtualSelect { this.uniqueId = this.getUniqueId(); this.shouldFocusWrapperOnClose = true; // Initialize focus management property this.isClosing = false; + /** true from closeDropbox() until the next openDropbox() - see closeDropbox() */ + this.isSilentServerSearch = false; this.ariaSetSize = 0; this.ariaMetadataDirty = true; } @@ -1954,8 +1965,22 @@ export class VirtualSelect { this.setVisibleOptionsCount(); DomUtils.removeClass(this.$allWrappers, 'server-searching'); - /** replace the "loading" message with the outcome of the fetch */ - if (this.isInitialized) { + /** + * Replace the "loading" message with the outcome of the fetch - for an open dropdown. + * + * isOpened() alone is not enough: the `closed` class arrives with the hide transition, + * so a response landing just after Escape still saw an "open" dropdown. The silent flag + * (see closeDropbox) covers that window and the whole closed period; opening lifts it. + * + * The count always describes the list as rendered *now*, not the fetch that triggered it. + * setServerOptions() carries no request identity, so responses cannot be matched to + * searches - when a stale response overwrites the list of an open dropdown, what is + * announced is exactly what the user sees, and the newer response re-announces when it + * lands. The local path announces the currently visible matches on the same principle + * (announceSearchResults additionally requires focus in the search input, which a + * response arriving whenever the host answers cannot demand). + */ + if (this.isInitialized && !this.isSilentServerSearch && this.isOpened()) { this.announce(this.getResultsCountMessage()); } } @@ -2966,6 +2991,15 @@ export class VirtualSelect { // Add to open instances VirtualSelect.openInstances.add(this); + /** + * The silence a close imposed (see closeDropbox) must not outlive the closed state: + * from now on the list is visible, so a fetch that fires or resolves against this open + * dropdown describes what the user is looking at and has to be announced - including a + * host push refreshing the options, and the close-time reset when it lands only after + * the user has already reopened. + */ + this.isSilentServerSearch = false; + DomUtils.setAttr(this.$dropboxWrapper, 'tabindex', '0'); DomUtils.setAria(this.$dropboxWrapper, 'hidden', false); @@ -3116,6 +3150,28 @@ export class VirtualSelect { } finally { this.isClosing = false; } + + /** + * From here until the next open, no server-search announcement may reach the live region: + * the dropdown the messages would describe is gone. Closing spoke twice for an interaction + * the user never made - the reset above schedules a fetch of its own, which said + * loadingText one searchDelay after the dropbox had closed and then the result count when + * the host responded. The local path already refuses to announce a reset it performed + * itself (announceSearchResults); this flag is the same rule for the server path. + * + * A flag set for the whole closed period, rather than one latched onto the scheduled + * fetch, because the close cannot see everything that is still going to speak: + * setSearchValue('') early-returns when the user already emptied the box (leaving *their* + * pending fetch to fire after the close), and a response to an earlier search can land + * mid hide-transition, while isOpened() is still true. openDropbox() lifts the silence, + * so it cannot leak into the next open either. + * + * The reset fetch itself is deliberately left running - it is what restores the + * unfiltered list for the next open, since opening does not search. + */ + if (this.hasServerSearch) { + this.isSilentServerSearch = true; + } } afterHidePopper() { @@ -3885,8 +3941,16 @@ export class VirtualSelect { DomUtils.removeClass(this.$allWrappers, 'has-no-search-results'); DomUtils.addClass(this.$allWrappers, 'server-searching'); - /** the spinner is a visual-only cue; announce that a fetch is in flight */ - this.announce(this.loadingText); + /** + * The spinner is a visual-only cue; announce that a fetch is in flight - but only for a + * dropdown the user can see. The silent flag covers the closed period (see closeDropbox); + * isOpened() covers fetches the component issued on its own while closed, such as the + * search reset showOptionsOnlyOnSearch forces during construction, which otherwise spoke + * loadingText on a page the user had not touched yet. + */ + if (this.isInitialized && !this.isSilentServerSearch && this.isOpened()) { + this.announce(this.loadingText); + } this.setSelectedOptions(); this.onServerSearch(this.searchValue, this);