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
192 changes: 192 additions & 0 deletions cypress/e2e/a11y-server-search-announcements.cy.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => {
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', '');
});
});
});
72 changes: 68 additions & 4 deletions src/virtual-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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());
}
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down