Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
57cad49
fix(a11y): close the dropdown on Escape in every layout (AI-2 / A11Y-02)
gnbm Aug 3, 2026
06aabd1
fix(a11y): expose Select All as a checkbox with state and Space activ…
gnbm Aug 3, 2026
9738f3a
feat(a11y): announce status changes through a per-instance live regio…
gnbm Aug 4, 2026
d8f4928
fix(a11y): expose required and error state, with an announced message…
gnbm Aug 4, 2026
3d4c37b
feat(security): page-level defaults so a host can enable escaping cen…
gnbm Aug 4, 2026
da2a7d7
fix(a11y): advertise multi-selection on the listbox (AI-10 / A11Y-06)
gnbm Aug 4, 2026
1f977b3
fix(a11y): raise Select All and tag clear buttons to the 24x24 target…
gnbm Aug 4, 2026
ea14b7a
fix(a11y): navigate options with the arrows from the search input (AI…
gnbm Aug 4, 2026
5157c53
test: open the dropdown deterministically in the flag-icon case
gnbm Aug 4, 2026
1f57f1b
perf: take the O(n) ARIA scan off the render path and coalesce scroll…
gnbm Aug 4, 2026
b36ef0b
fix(a11y): strip markup and escape quotes in every aria-label
gnbm Aug 4, 2026
46908da
perf: reuse one text measurer instead of creating a node per tag
gnbm Aug 4, 2026
4a0d6f3
feat(a11y): honour the reduce-motion preference
gnbm Aug 4, 2026
62368a0
docs(test): describe what each spec protects instead of citing audit IDs
gnbm Aug 4, 2026
965e904
fix(a11y): drop the option highlight when the dropbox closes (AI-1f)
gnbm Aug 4, 2026
3abcf97
test: open from an asserted state instead of toggling (AI-1e)
gnbm Aug 4, 2026
da295ad
fix(a11y): keep the closing dropbox from taking the highlight back (A…
gnbm Aug 5, 2026
ae43823
test: pin the search value and scroll position in openFresh() (AI-1e)
gnbm Aug 5, 2026
fba6917
fix(a11y): close the dropdown on Escape in every layout (AI-2 / A11Y-02)
gnbm Aug 3, 2026
a213eb5
fix(a11y): expose Select All as a checkbox with state and Space activ…
gnbm Aug 3, 2026
5cf16bb
feat(a11y): announce status changes through a per-instance live regio…
gnbm Aug 4, 2026
8828ffb
fix(a11y): expose required and error state, with an announced message…
gnbm Aug 4, 2026
f08a9f6
feat(security): page-level defaults so a host can enable escaping cen…
gnbm Aug 4, 2026
a80d531
fix(a11y): advertise multi-selection on the listbox (AI-10 / A11Y-06)
gnbm Aug 4, 2026
e9b1d1f
fix(a11y): raise Select All and tag clear buttons to the 24x24 target…
gnbm Aug 4, 2026
04abbe9
fix(a11y): navigate options with the arrows from the search input (AI…
gnbm Aug 4, 2026
d9ca106
test: open the dropdown deterministically in the flag-icon case
gnbm Aug 4, 2026
5855f65
perf: take the O(n) ARIA scan off the render path and coalesce scroll…
gnbm Aug 4, 2026
8c2bf82
fix(a11y): strip markup and escape quotes in every aria-label
gnbm Aug 4, 2026
fa0d076
perf: reuse one text measurer instead of creating a node per tag
gnbm Aug 4, 2026
615d906
feat(a11y): honour the reduce-motion preference
gnbm Aug 4, 2026
d298694
docs(test): describe what each spec protects instead of citing audit IDs
gnbm Aug 4, 2026
f4a40f8
fix(a11y): drop the option highlight when the dropbox closes (AI-1f)
gnbm Aug 4, 2026
54987e2
test: open from an asserted state instead of toggling (AI-1e)
gnbm Aug 4, 2026
44233ce
fix(a11y): keep the closing dropbox from taking the highlight back (A…
gnbm Aug 5, 2026
9e297ed
test: pin the search value and scroll position in openFresh() (AI-1e)
gnbm Aug 5, 2026
9d7d35c
test: stop chaining key presses onto virtualised option nodes (AI-1e)
gnbm Aug 5, 2026
6c42b63
test: close the remaining order coupling where it can actually bite (…
gnbm Aug 5, 2026
5a48403
fix(a11y): make the isClosing guard exception-safe (AI-1f)
gnbm Aug 5, 2026
b31bde1
fix(security): harden the name attribute, value maps and quote escapi…
gnbm Aug 5, 2026
faef03c
fix(security): store option values verbatim so they stay addressable …
gnbm Aug 5, 2026
809b1c9
fix(a11y): stop chrome label props breaking out of their attributes (…
gnbm Aug 5, 2026
7d340cf
Merge origin/gm/a11y-improvements-v1, keeping the local rebuilt history
gnbm Aug 5, 2026
8f8e707
test(security): fix two spec bugs the first Cypress run exposed (AI-2…
gnbm Aug 5, 2026
234366a
test(security): type the search term with real key events (AI-22)
gnbm Aug 5, 2026
35540fc
fix(a11y): stop centering the Select All checkbox in its hit-box
gnbm Aug 5, 2026
7c878f0
fix(a11y): stop centering the Select All checkbox in its hit-box
gnbm Aug 5, 2026
97c9590
fix(a11y): make the error and status announcements actually reach the…
gnbm Aug 5, 2026
7886c25
Merge branch 'gm/a11y-improvements-v1' of https://github.com/sa-si-de…
gnbm Aug 5, 2026
9108828
fix(a11y): finish the entity decode, silence uninvited announcements,…
gnbm Aug 5, 2026
6762b0c
chore: re-pin build output to master, it was committed again
gnbm Aug 5, 2026
b6e4786
test(security): give every XSS payload its own marker (AI-26)
gnbm Aug 5, 2026
4cac73f
test(a11y): fix four spec-quality findings from the second review (AI…
gnbm Aug 5, 2026
8f280c1
Update docs
gnbm Aug 5, 2026
e4da35a
test(perf): reach the last-instance state without tearing down the pa…
gnbm Aug 5, 2026
cbdc5de
fix(a11y): keep status text out of the accessible name, settle the ne…
gnbm Aug 6, 2026
a2e41b7
chore: re-pin docs/assets to master, it was committed again
gnbm Aug 7, 2026
1f74b35
docs(security): stop claiming option `value` is inserted as raw HTML
gnbm Aug 7, 2026
dcf61d5
docs: give resetGlobalDefaults() and getGlobalDefaults() their own he…
gnbm Aug 7, 2026
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
154 changes: 154 additions & 0 deletions cypress/e2e/a11y-aria-label.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/** cSpell:ignore vscomp */

/**
* Accessible names must be plain text.
*
* Option labels may legitimately contain markup - a flag icon, <b>, a <br>. Those labels are
* also interpolated into aria-label attributes, where markup is meaningless: it reached the
* screen reader as tag soup ("i class= flag France"), and a double quote in a label closed
* the attribute early so the rest of the name was silently lost.
*
* WCAG 4.1.2 Name, Role, Value (A) and 1.1.1 Non-text Content (A).
*/

import { mountVs, unmountVs } from '../support/mount';

describe('A11y: accessible names are plain text', { testIsolation: true }, () => {
const mountId = 'vs-aria-label';

const mount = (options: unknown[], extra: Record<string, unknown> = {}) => {
cy.viewport(1280, 800);
cy.visit('get-started');
cy.window().then((win) => mountVs(win, mountId, { options, ...extra }));
cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
};

const option = (value: string) => cy.get(`#${mountId}`).find(`.vscomp-option[data-value="${value}"]`);

afterEach(() => {
cy.window().then((win) => unmountVs(win, mountId));
});

it('strips markup from a grouped option name', () => {
mount([{ label: 'Europe', options: [{ label: '<i class="flag"></i> France', value: 'fr' }] }]);

option('fr').should('have.attr', 'aria-label').and('contain', 'France').and('not.contain', '<i');
});

it('strips markup from the group name carried into its children', () => {
mount([{ label: '<b>Europe</b>', options: [{ label: 'France', value: 'fr' }] }]);

option('fr')
.should('have.attr', 'aria-label')
.and('contain', 'Europe')
.and('not.contain', '<b')
.and('not.contain', '</b>');
});

it('does not run words together where a tag was removed', () => {
mount([{ label: 'Europe', options: [{ label: 'Paris<br>France', value: 'fr' }] }]);

// "ParisFrance" would be the result of stripping tags to an empty string.
option('fr').should('have.attr', 'aria-label').and('contain', 'Paris France');
});

it('keeps a double quote in the label from truncating the name', () => {
mount([{ label: 'Europe', options: [{ label: 'The "City" of Light', value: 'fr' }] }]);

option('fr')
.should('have.attr', 'aria-label')
.and('contain', 'City')
// Everything after the quote survived, i.e. the attribute was not broken out of.
.and('contain', 'Light');
});

it('strips markup from the group header name too', () => {
mount([{ label: '<i class="flag"></i> Europe', options: [{ label: 'France', value: 'fr' }] }], {
multiple: true,
});

cy.get(`#${mountId}`)
.find('.vscomp-option.group-title')
.first()
.should('have.attr', 'aria-label')
.and('contain', 'Europe')
.and('not.contain', '<i');
});

/**
* The stripping must also work when `enableSecureText` is on — which is the mode this release
* actively promotes, via setGlobalDefaults() and the OutSystems wrapper default.
*
* With escaping on, the label reaches getAriaLabelText() *already escaped*, so its
* `/<[^>]+>/gi` tag pattern finds no `<` to match: the markup passed straight through and the
* accessible name became the literal tag soup `<i class="flag"></i> France`. Same option, same
* code path, opposite outcome depending on a security flag — so the fix was a no-op exactly
* where the escaping it depends on is enabled.
*/
[false, true].forEach((enableSecureText) => {
it(`strips markup regardless of enableSecureText (${enableSecureText})`, () => {
mount([{ label: 'Europe', options: [{ label: '<i class="flag"></i> France', value: 'fr' }] }], {
enableSecureText,
});

option('fr')
.should('have.attr', 'aria-label')
.and('contain', 'France')
.and('not.contain', '<i')
.and('not.contain', 'class=');
});
});

it('names a label containing a no-break space without the entity', () => {
/**
* `secureText()` escapes exactly four characters, because that is what serialising a text node
* through `innerHTML` emits: `&`, `<`, `>` and U+00A0 (as `&nbsp;`). The decoder undid the first
* three, so a no-break space survived into the accessible name as the literal `&nbsp;`.
*
* The character is written as an escape on purpose — a raw NBSP is invisible in a diff and is
* easily "tidied" into an ordinary space, which would silently stop testing anything.
*/
mount([{ label: 'Europe', options: [{ label: 'Item\u00A0A', value: 'nb' }] }], {
enableSecureText: true,
});

// The entity must not survive. The character itself is normalised to an ordinary space,
// because getPlainText() collapses whitespace and JavaScript's `\s` matches U+00A0 - which
// is the right outcome for a name that will be spoken.
option('nb')
.should('have.attr', 'aria-label')
.and('contain', 'Item A')
.and('not.contain', '&nbsp;');

// ...while the visible label keeps the real no-break space.
option('nb').find('.vscomp-option-text').should(($text) => {
expect($text.text()).to.contain('Item\u00A0A');
});
});

it('announces an ampersand in a label as an ampersand', () => {
// The escaped storage form must not leak into the accessible name as `&amp;`.
mount([{ label: 'Europe', options: [{ label: 'Tom & Jerry', value: 'tj' }] }], {
enableSecureText: true,
});

option('tj').should('have.attr', 'aria-label').and('contain', 'Tom & Jerry').and('not.contain', '&amp;');
});

it('strips markup from the tag clear button name', () => {
mount([{ label: '<i class="flag"></i> France', value: 'fr' }], {
multiple: true,
showValueAsTags: true,
ariaLabelTagClearButtonText: 'Remove option',
});

option('fr').click();

cy.get(`#${mountId}`)
.find('.vscomp-value-tag-clear-button')
.should('have.attr', 'aria-label')
.and('contain', 'France')
.and('contain', 'Remove option')
.and('not.contain', '<i');
});
});
185 changes: 185 additions & 0 deletions cypress/e2e/a11y-close-clears-highlight.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/** cSpell:ignore vscomp */

/**
* Closing the dropbox must end option navigation, immediately.
*
* Every default instance closes through a popover hide transition, so `afterHidePopper()` -
* the only place that dropped the highlight - ran ~200ms after `closeDropbox()` returned.
* Until then the previous `.focused` option and `focusedOptionIndex` survived the close, so a
* dropdown reopened inside that window resumed navigation one step further down the list than
* the user could see. On a grouped multi-select that shifted Enter off the group title and onto
* its first child, so "select the whole group" silently became "select one option".
*
* WCAG 2.1.1 Keyboard (A) and 4.1.2 Name, Role, Value (A) - `aria-activedescendant` and the
* visible highlight have to agree with where the next arrow key will actually go.
*/

import { mountVs, unmountVs } from '../support/mount';

const GROUPS = [
{ label: 'Group 1', options: [{ label: 'Option 1-1', value: '1-1' }, { label: 'Option 1-2', value: '1-2' }] },
{ label: 'Group 2', options: [{ label: 'Option 2-1', value: '2-1' }] },
];

describe('A11y: closing the dropbox clears option navigation state', { testIsolation: true }, () => {
const mountId = 'vs-close-clears-highlight';

const mount = (extra: Record<string, unknown> = {}) => {
cy.viewport(1280, 800);
cy.visit('get-started');
cy.window().then((win) => mountVs(win, mountId, { options: GROUPS, multiple: true, ...extra }));
};

const openAndHighlightFirst = () => {
cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).pressKeys('ArrowDown');
cy.get(`#${mountId}`).find('.vscomp-option.focused').should('have.attr', 'data-index', '0');
};

afterEach(() => {
cy.window().then((win) => unmountVs(win, mountId));
});

it('drops the highlight in the same tick as the close, not when the transition ends', () => {
mount();
openAndHighlightFirst();

cy.get(`#${mountId}`).then(($ele) => {
const vs = $ele[0].virtualSelect;

vs.closeDropbox();

/**
* Read in the same tick the close was requested. isOpened() is still true here, which
* is the point: this pins the asynchronous popover path rather than the synchronous
* fallback, so the test cannot pass for the wrong reason.
*/
expect(vs.isOpened(), 'still mid hide-transition').to.equal(true);
expect(vs.$dropboxContainer.querySelector('.vscomp-option.focused'), 'highlighted option').to.equal(null);
expect(vs.focusedOptionIndex, 'focusedOptionIndex').to.equal(null);
});
});

it('clears aria-activedescendant on close', () => {
mount();
openAndHighlightFirst();

cy.get(`#${mountId}`).find('.vscomp-wrapper').should('have.attr', 'aria-activedescendant');

cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect.closeDropbox());

cy.get(`#${mountId}`).find('.vscomp-wrapper').should('not.have.attr', 'aria-activedescendant');
});

/**
* Reopened in the *same tick* as the close, deliberately. Waiting for the `closed` class
* first would wait out `afterHidePopper()`, which clears the highlight on its own - so the
* case could never observe the bug it exists for. This is the user-visible symptom: reopen
* before the hide transition finishes and navigation must still start at the top.
*/
it('starts navigation at the first option again when reopened mid hide-transition', () => {
mount();
openAndHighlightFirst();
cy.get(`#${mountId}`).pressKeys('ArrowDown');
cy.get(`#${mountId}`).find('.vscomp-option.focused').should('have.attr', 'data-index', '1');

cy.get(`#${mountId}`).then(($ele) => {
const vs = $ele[0].virtualSelect;

vs.closeDropbox();
expect(vs.isOpened(), 'still mid hide-transition').to.equal(true);
vs.openDropbox();
});

cy.get(`#${mountId}`).pressKeys('ArrowDown');

// data-index 2 here would mean the pre-close highlight was carried over.
cy.get(`#${mountId}`).find('.vscomp-option.focused').should('have.attr', 'data-index', '0');
});

/**
* The same close, with a filter typed. Clearing the filter is the last thing closeDropbox()
* does, and it used to re-highlight the first visible option - undoing the clear above and
* pulling DOM focus onto an option that is about to be hidden.
*/
it('keeps the highlight cleared when a search value has to be cleared too', () => {
mount({ search: true });

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).find('.vscomp-search-input').type('Option');
cy.get(`#${mountId}`).pressKeys('ArrowDown');
cy.get(`#${mountId}`).find('.vscomp-option.focused').should('exist');

cy.get(`#${mountId}`).then(($ele) => {
const vs = $ele[0].virtualSelect;

vs.closeDropbox();

expect(vs.isOpened(), 'still mid hide-transition').to.equal(true);
expect(vs.searchValue, 'search value').to.equal('');
expect(vs.$dropboxContainer.querySelector('.vscomp-option.focused'), 'highlighted option').to.equal(null);
expect(vs.focusedOptionIndex, 'focusedOptionIndex').to.equal(null);
expect(vs.$wrapper.getAttribute('aria-activedescendant'), 'aria-activedescendant').to.equal(null);
});

// Focus must not have been dragged into the dropbox that is being hidden.
cy.get(`#${mountId}`).find('.vscomp-wrapper').should('have.class', 'closed');
cy.focused().should('have.class', 'vscomp-wrapper');
});

it('toggles a whole group on and off with Enter on its group title', () => {
mount();
openAndHighlightFirst();

cy.get(`#${mountId}`).find('.vscomp-option.focused').should('have.class', 'group-title');

/**
* Asserted on the value array rather than on `.vscomp-value`: the rendered text switches
* between a list of labels and an "N options selected" summary depending on
* `noOfDisplayValues`, which has nothing to do with what this case is about.
*/
cy.get(`#${mountId}`).pressKeys('Enter');
cy.get(`#${mountId}`).should(($ele) => {
expect($ele[0].virtualSelect.selectedValues, 'after the first Enter').to.deep.equal(['1-1', '1-2']);
});

cy.get(`#${mountId}`).pressKeys('Enter');
cy.get(`#${mountId}`).should(($ele) => {
expect($ele[0].virtualSelect.selectedValues, 'after the second Enter').to.deep.equal([]);
});
});

/**
* Negative control for the change that stopped clearing the highlight from moving DOM focus:
* Escape must still hand focus back to the combobox rather than leaving it on an option
* inside a dropbox that is being hidden.
*/
it('still returns focus to the combobox when Escape closes the dropbox', () => {
mount();
openAndHighlightFirst();

cy.get(`#${mountId}`).find('.vscomp-wrapper').trigger('keydown', { key: 'Escape', keyCode: 27, which: 27 });

cy.get(`#${mountId}`).find('.vscomp-wrapper').should('have.class', 'closed');
cy.focused().should('have.class', 'vscomp-wrapper');
});

/**
* Same control on the layout where it actually bites. showAsPopup skips initDropboxPopover(),
* so closeDropbox() runs afterHidePopper() synchronously - i.e. right after the wrapper
* refocus and while the options are still visible and therefore still focusable. That is the
* path where clearing the highlight used to steal focus for real.
*/
it('still returns focus to the combobox when a popup layout closes', () => {
mount({ showAsPopup: true });

cy.get(`#${mountId}`).find('.vscomp-toggle-button').click();
cy.get(`#${mountId}`).pressKeys('ArrowDown');
cy.get(`#${mountId}`).find('.vscomp-option.focused').should('exist');

cy.get(`#${mountId}`).find('.vscomp-wrapper').trigger('keydown', { key: 'Escape', keyCode: 27, which: 27 });

cy.get(`#${mountId}`).find('.vscomp-wrapper').should('have.class', 'closed');
cy.focused().should('have.class', 'vscomp-wrapper');
});
});
Loading