diff --git a/cypress/e2e/a11y-aria-label.cy.ts b/cypress/e2e/a11y-aria-label.cy.ts new file mode 100644 index 00000000..b8bdac2e --- /dev/null +++ b/cypress/e2e/a11y-aria-label.cy.ts @@ -0,0 +1,154 @@ +/** cSpell:ignore vscomp */ + +/** + * Accessible names must be plain text. + * + * Option labels may legitimately contain markup - a flag icon, , a
. 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 = {}) => { + 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: ' France', value: 'fr' }] }]); + + option('fr').should('have.attr', 'aria-label').and('contain', 'France').and('not.contain', ' { + mount([{ label: 'Europe', options: [{ label: 'France', value: 'fr' }] }]); + + option('fr') + .should('have.attr', 'aria-label') + .and('contain', 'Europe') + .and('not.contain', ''); + }); + + it('does not run words together where a tag was removed', () => { + mount([{ label: 'Europe', options: [{ label: 'Paris
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: ' 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', ']+>/gi` tag pattern finds no `<` to match: the markup passed straight through and the + * accessible name became the literal tag soup ` 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: ' France', value: 'fr' }] }], { + enableSecureText, + }); + + option('fr') + .should('have.attr', 'aria-label') + .and('contain', 'France') + .and('not.contain', ' { + /** + * `secureText()` escapes exactly four characters, because that is what serialising a text node + * through `innerHTML` emits: `&`, `<`, `>` and U+00A0 (as ` `). The decoder undid the first + * three, so a no-break space survived into the accessible name as the literal ` `. + * + * 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', ' '); + + // ...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 `&`. + 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', '&'); + }); + + it('strips markup from the tag clear button name', () => { + mount([{ label: ' 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', ' { + const mountId = 'vs-close-clears-highlight'; + + const mount = (extra: Record = {}) => { + 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'); + }); +}); diff --git a/cypress/e2e/a11y-escape-close.cy.ts b/cypress/e2e/a11y-escape-close.cy.ts new file mode 100644 index 00000000..dd30e395 --- /dev/null +++ b/cypress/e2e/a11y-escape-close.cy.ts @@ -0,0 +1,123 @@ +/** cSpell:ignore vscomp */ + +/** + * Escape must dismiss the dropdown, whatever layout it is rendered in. + * + * WCAG 2.1.1 Keyboard (A) and 2.1.2 No Keyboard Trap (A). + * + * Escape only closed the dropdown when an external `dropboxWrapper` existed or the dropdown + * was shown as a popup. With the default `dropboxWrapper: 'self'` on a desktop viewport the + * guard resolved to `this.$dropboxWrapper`, which is `undefined`, so the branch was skipped + * and the dropdown stayed open — leaving keyboard users with no way to dismiss it. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('A11y: Escape closes the dropdown', () => { + const mountId = 'vs-a11y-escape'; + + const isExpanded = (expected: boolean) => + cy.get(`#${mountId}`).find('.vscomp-wrapper').should('have.attr', 'aria-expanded', String(expected)); + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + context('default config (dropboxWrapper: "self", desktop viewport)', () => { + beforeEach(() => { + // Wider than the 576px popup breakpoint, so showAsPopup is false — the broken path. + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(6), search: true })); + }); + + it('closes when Escape is pressed with focus on the search input', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + isExpanded(true); + + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('{esc}'); + + isExpanded(false); + cy.get(`#${mountId}`).find('.vscomp-dropbox-container').should('not.be.visible'); + }); + + it('closes when Escape is pressed with an option focused', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + isExpanded(true); + + // ArrowDown from the search input moves the highlight; the option itself takes DOM focus + // in non-search navigation, so target the focused option directly. + cy.get(`#${mountId}`).find('.vscomp-option').first().focus().type('{esc}'); + + isExpanded(false); + }); + + it('returns focus to the combobox after Escape so the user is not stranded', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + isExpanded(true); + + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('{esc}'); + + isExpanded(false); + cy.focused().should('have.class', 'vscomp-wrapper'); + }); + }); + + // The fix reworked which element containment is tested against, so the two layouts that + // already worked are pinned here to prove they still do. + context('popup layout (viewport below the popup breakpoint)', () => { + it('still closes on Escape', () => { + cy.viewport(480, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(6), search: true })); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + isExpanded(true); + + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('{esc}'); + + isExpanded(false); + }); + }); + + context('external dropboxWrapper', () => { + it('still closes on Escape when the dropbox is portalled out of the wrapper', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => { + const host = win.document.createElement('div'); + host.id = 'vs-a11y-escape-portal'; + win.document.body.appendChild(host); + + mountVs(win, mountId, { + options: makeOptions(6), + search: true, + dropboxWrapper: '#vs-a11y-escape-portal', + }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + isExpanded(true); + + cy.get('#vs-a11y-escape-portal').find('.vscomp-search-input').focus().type('{esc}'); + + isExpanded(false); + + cy.window().then((win) => win.document.getElementById('vs-a11y-escape-portal')?.remove()); + }); + }); + + context('keepAlwaysOpen', () => { + it('ignores Escape, because the dropdown is not dismissible by design', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => + mountVs(win, mountId, { options: makeOptions(6), search: true, keepAlwaysOpen: true }), + ); + + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('{esc}'); + + cy.get(`#${mountId}`).find('.vscomp-dropbox-container').should('be.visible'); + }); + }); +}); diff --git a/cypress/e2e/a11y-listbox-multiselectable.cy.ts b/cypress/e2e/a11y-listbox-multiselectable.cy.ts new file mode 100644 index 00000000..80f04e13 --- /dev/null +++ b/cypress/e2e/a11y-listbox-multiselectable.cy.ts @@ -0,0 +1,59 @@ +/** cSpell:ignore vscomp multiselectable */ + +/** + * A multi-select listbox must say so, or assistive technology presents it as single-select. + * + * WCAG 4.1.2 Name, Role, Value (A). + * + * The options container carries role="listbox" but never advertised that more than one + * option could be chosen, so assistive technology presented a multi-select dropdown with + * single-select semantics. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('A11y: listbox advertises multi-selection', () => { + const mountId = 'vs-a11y-multiselectable'; + + const listbox = () => cy.get(`#${mountId}`).find('.vscomp-options-container'); + + const mount = (extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5), ...extra })); + }; + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('sets aria-multiselectable="true" in multiple mode', () => { + mount({ multiple: true }); + + listbox().should('have.attr', 'role', 'listbox'); + listbox().should('have.attr', 'aria-multiselectable', 'true'); + }); + + it('omits aria-multiselectable for a single select', () => { + mount(); + + listbox().should('have.attr', 'role', 'listbox'); + listbox().should('not.have.attr', 'aria-multiselectable'); + }); + + it('still applies when multiple comes from the element attribute', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => { + const $ele = win.document.createElement('div'); + $ele.id = mountId; + $ele.setAttribute('multiple', ''); + win.document.body.appendChild($ele); + + // @ts-expect-error - VirtualSelect is attached to window by the bundle + win.VirtualSelect.init({ ele: $ele, options: makeOptions(5) }); + }); + + listbox().should('have.attr', 'aria-multiselectable', 'true'); + }); +}); diff --git a/cypress/e2e/a11y-live-region.cy.ts b/cypress/e2e/a11y-live-region.cy.ts new file mode 100644 index 00000000..d66240c4 --- /dev/null +++ b/cypress/e2e/a11y-live-region.cy.ts @@ -0,0 +1,258 @@ +/** cSpell:ignore vscomp */ + +/** + * Status changes must be announced, not shown only on screen. + * + * WCAG 4.1.3 Status Messages (AA). + * + * The component shipped a `.vscomp-live-region` rule in the stylesheet but no JavaScript + * ever created the element, so there were zero live regions in the DOM. Search result + * counts, "no results", server-search loading and selection changes were all conveyed + * visually only — a screen reader user typing into the search box heard nothing. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('A11y: status announcements via a live region', () => { + const mountId = 'vs-a11y-live'; + + const liveRegion = () => cy.get(`#${mountId}`).find('.vscomp-live-region'); + + const mount = (extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5), search: true, ...extra })); + }; + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + context('the region itself', () => { + beforeEach(() => mount()); + + it('creates exactly one polite status region per instance', () => { + liveRegion().should('have.length', 1); + liveRegion().should('have.attr', 'role', 'status'); + liveRegion().should('have.attr', 'aria-live', 'polite'); + // Atomic so the whole message is re-read rather than just the changed words. + liveRegion().should('have.attr', 'aria-atomic', 'true'); + }); + + it('is visually hidden but not hidden from assistive technology', () => { + // aria-hidden / display:none / visibility:hidden would all silence it. + liveRegion().should('not.have.attr', 'aria-hidden'); + liveRegion().should('have.css', 'position', 'absolute'); + liveRegion().invoke('outerWidth').should('be.lessThan', 2); + }); + + it('starts empty so nothing is announced on page load', () => { + liveRegion().should('have.text', ''); + }); + + it('sits outside the combobox element, so it cannot leak into its accessible name', () => { + // Visually-hidden text inside a combobox that has no aria-label still joins the + // combobox's name-from-contents computation; a sibling of the combobox cannot. + liveRegion().should('have.length', 1); + cy.get(`#${mountId}`).find('[role="combobox"] .vscomp-live-region').should('not.exist'); + }); + }); + + context('search results', () => { + beforeEach(() => mount()); + + it('announces the number of matches while typing', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('Option'); + + liveRegion().should('have.text', '5 results available'); + }); + + it('uses the singular form for a single match', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('Option 3'); + + liveRegion().should('have.text', '1 result available'); + }); + + it('announces the no-results message when the search matches nothing', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('zzzzzz'); + + liveRegion().should('have.text', 'No results found'); + }); + + it('does not announce a stale count when the dropdown is merely closed', () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('zzzzzz'); + liveRegion().should('have.text', 'No results found'); + + // Closing resets the search internally; that must not re-announce a result count. + cy.get(`#${mountId}`).find('.vscomp-search-input').type('{esc}'); + + liveRegion().should('not.have.text', '5 results available'); + }); + + it('honours custom result-count text for localisation', () => { + mount({ searchResultsText: 'Treffer verfügbar', searchResultText: 'Treffer verfügbar' }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('Option'); + + liveRegion().should('have.text', '5 Treffer verfügbar'); + }); + }); + + context('selection changes', () => { + it('announces the selection summary for a multi-select', () => { + mount({ multiple: true }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + liveRegion().should('have.text', '1 option selected'); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o2"]').click(); + + liveRegion().should('have.text', '2 options selected'); + }); + + it('announces the chosen label for a single select', () => { + mount(); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o3"]').click(); + + liveRegion().should('have.text', 'Option 3 selected'); + }); + + /** + * The announcement is read as text, so it must carry human-readable text. + * + * A single select announces the chosen label, and with `enableSecureText: true` the stored + * label is HTML-escaped by design (it is rendered as HTML elsewhere). Announcing it verbatim + * put the escape sequence into the region: `Tom & Jerry selected`, which a screen reader + * reads out as the entity rather than as "Tom and Jerry". The region is written with + * textContent, so the escaping buys nothing here and costs intelligibility. + */ + it('announces an ampersand in a label as an ampersand', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => + mountVs(win, mountId, { + options: [ + { label: 'Tom & Jerry', value: 'tj' }, + { label: 'Plain', value: 'p' }, + ], + enableSecureText: true, + }), + ); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="tj"]').click(); + + liveRegion().should('have.text', 'Tom & Jerry selected'); + }); + + /** + * The fourth character the text-node serialiser escapes, and the one the decoder first missed. + * + * `secureText()` emits `&`, `<`, `>` and U+00A0 (as ` `) — four, not three — so a + * no-break space in a label was announced as the literal ` ` even after the entities + * above were handled. + */ + it('announces a label containing a no-break space without the entity', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => + mountVs(win, mountId, { + options: [ + { label: 'Item\u00A0A', value: 'nb' }, + { label: 'Plain', value: 'p' }, + ], + enableSecureText: true, + }), + ); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="nb"]').click(); + + // The entity must not survive; the character is normalised to a space by the whitespace + // collapse in getPlainText(), which is correct for speech. + liveRegion().should('not.contain', ' '); + liveRegion().should('have.text', 'Item A selected'); + }); + + /** + * Decoding alone is not enough, which is what this case pins. + * + * A label can carry markup as well as escaping. Undoing the escaping turns + * `<i class="flag"></i> France` into ` France` — still not + * speech, just a different kind of noise. The region is reduced to plain text, the same + * treatment accessible names get. + */ + it('announces a label containing markup as words, not as tags', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => + mountVs(win, mountId, { + options: [ + { label: ' France', value: 'fr' }, + { label: 'Plain', value: 'p' }, + ], + enableSecureText: true, + }), + ); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="fr"]').click(); + + liveRegion().should('have.text', 'France selected'); + }); + + it('announces when the selection is cleared', () => { + mount({ multiple: true }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + liveRegion().should('have.text', '1 option selected'); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + liveRegion().should('have.text', 'No options selected'); + }); + + it('announces the result of Select All', () => { + mount({ multiple: true }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-toggle-all-button').click(); + + liveRegion().should('have.text', '5 options selected'); + }); + + it('does not announce a value supplied at initialisation', () => { + mount({ selectedValue: 'o2' }); + + liveRegion().should('have.text', ''); + }); + }); + + context('lifecycle', () => { + it('removes the region when the instance is destroyed', () => { + mount(); + liveRegion().should('have.length', 1); + + // Scoped to this instance's region: the docs page hosts other instances, and each + // legitimately owns a live region of its own. + liveRegion() + .invoke('attr', 'id') + .then((regionId) => { + cy.window().then((win) => unmountVs(win, mountId)); + + cy.get(`#${mountId}`).should('not.exist'); + cy.get(`#${regionId}`).should('not.exist'); + }); + }); + }); +}); diff --git a/cypress/e2e/a11y-reduced-motion.cy.ts b/cypress/e2e/a11y-reduced-motion.cy.ts new file mode 100644 index 00000000..d266ab87 --- /dev/null +++ b/cypress/e2e/a11y-reduced-motion.cy.ts @@ -0,0 +1,92 @@ +/** cSpell:ignore vscomp */ + +/** + * The dropdown must respect the user's OS-level "reduce motion" preference. + * + * The open/close animation is driven from both CSS and JS, so honouring the preference in the + * stylesheet alone is not enough - the popover would still animate for + * showDuration/hideDuration milliseconds. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('A11y: prefers-reduced-motion', { testIsolation: true }, () => { + const mountId = 'vs-reduced-motion'; + + it('zeroes the JS-driven open/close durations when reduce is requested', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + + cy.window().then((win) => { + // Force the query to report "reduce" before the instance reads it. + const originalMatchMedia = win.matchMedia.bind(win); + + win.matchMedia = ((query: string) => + query.includes('prefers-reduced-motion') + ? ({ + matches: true, + media: query, + addEventListener() {}, + removeEventListener() {}, + } as unknown as MediaQueryList) + : originalMatchMedia(query)) as typeof win.matchMedia; + + mountVs(win, mountId, { options: makeOptions(5) }); + }); + + cy.get(`#${mountId}`).then(($e) => { + const vs = $e[0].virtualSelect; + + expect(vs.showDuration, 'showDuration under reduced motion').to.equal(0); + expect(vs.hideDuration, 'hideDuration under reduced motion').to.equal(0); + }); + + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('keeps the configured durations when reduce is not requested', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5) })); + + cy.get(`#${mountId}`).then(($e) => { + const vs = $e[0].virtualSelect; + + expect(vs.showDuration, 'default showDuration').to.be.greaterThan(0); + expect(vs.hideDuration, 'default hideDuration').to.be.greaterThan(0); + }); + + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('ships a stylesheet rule that zeroes the transitions', () => { + cy.visit('get-started'); + + // Read the stylesheet the page actually loaded, rather than re-fetching the file: this + // proves the rule survived the build and is live in the document. + cy.document().then((doc) => { + const sheets = Array.from(doc.styleSheets).filter((sheet) => (sheet.href || '').includes('virtual-select')); + + expect(sheets.length, 'the component stylesheet is loaded').to.be.greaterThan(0); + + const mediaRules = sheets.flatMap((sheet) => { + try { + return Array.from(sheet.cssRules); + } catch { + // cross-origin sheets are not readable; none of ours are + return []; + } + }); + + // Duck-typed rather than `instanceof CSSMediaRule`: spec code and the application run + // in different realms, so the constructors are not the same object. + const reducedMotion = mediaRules.filter((rule) => + String((rule as CSSMediaRule).conditionText || '').includes('prefers-reduced-motion'), + ) as CSSMediaRule[]; + + expect(reducedMotion.length, 'a prefers-reduced-motion block is present').to.be.greaterThan(0); + expect(reducedMotion[0].cssText, 'it zeroes the transitions').to.include('transition-duration: 0s'); + expect(reducedMotion[0].cssText, 'it covers the dropbox').to.include('vscomp-dropbox'); + }); + }); +}); diff --git a/cypress/e2e/a11y-required-error.cy.ts b/cypress/e2e/a11y-required-error.cy.ts new file mode 100644 index 00000000..0d5dac79 --- /dev/null +++ b/cypress/e2e/a11y-required-error.cy.ts @@ -0,0 +1,290 @@ +/** cSpell:ignore vscomp */ + +/** + * A required field, and a failed validation, must be perceivable without relying on colour. + * + * WCAG 3.3.1 Error Identification (A), 1.4.1 Use of Colour (A), 4.1.2 Name/Role/Value (A). + * + * `required` was never exposed: the wrapper carried no aria-required, and a failed + * validate() only toggled a `has-error` class that changed the toggle button's border + * colour. There was no aria-invalid, no error message and no announcement — the failure + * was communicated by colour alone, and not at all to assistive technology. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('A11y: required and error state are exposed', () => { + const mountId = 'vs-a11y-required'; + + const wrapper = () => cy.get(`#${mountId}`).find('.vscomp-wrapper'); + const errorMessage = () => cy.get(`#${mountId}`).find('.vscomp-error-message'); + const liveRegion = () => cy.get(`#${mountId}`).find('.vscomp-live-region'); + const validate = () => cy.get(`#${mountId}`).then(($e) => $e[0].validate?.()); + + const mount = (extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5), ...extra })); + }; + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + context('aria-required', () => { + it('is exposed when the field is required', () => { + mount({ required: true }); + + wrapper().should('have.attr', 'aria-required', 'true'); + }); + + it('is absent when the field is optional', () => { + mount(); + + wrapper().should('not.have.attr', 'aria-required'); + }); + + it('follows toggleRequired() at runtime', () => { + mount(); + wrapper().should('not.have.attr', 'aria-required'); + + cy.get(`#${mountId}`).then(($e) => $e[0].toggleRequired?.(true)); + wrapper().should('have.attr', 'aria-required', 'true'); + + cy.get(`#${mountId}`).then(($e) => $e[0].toggleRequired?.(false)); + wrapper().should('not.have.attr', 'aria-required'); + }); + }); + + context('failed validation', () => { + beforeEach(() => mount({ required: true })); + + it('sets aria-invalid on the combobox', () => { + wrapper().should('not.have.attr', 'aria-invalid'); + + validate(); + + wrapper().should('have.attr', 'aria-invalid', 'true'); + }); + + it('renders a text error message, so the failure is not signalled by colour alone', () => { + validate(); + + errorMessage().should('be.visible'); + errorMessage().should('have.text', 'This field is required'); + }); + + it('renders the message outside the combobox element, so it cannot leak into its accessible name', () => { + // aria-describedby is the association; the text itself must not also sit inside the + // combobox, where it would join a name-from-contents computation for instances + // mounted without an aria-label. + validate(); + + errorMessage().should('be.visible'); + cy.get(`#${mountId}`).find('[role="combobox"] .vscomp-error-message').should('not.exist'); + }); + + it('associates the message with the combobox via aria-describedby', () => { + validate(); + + errorMessage() + .invoke('attr', 'id') + .then((errorId) => { + wrapper().should('have.attr', 'aria-describedby', errorId); + }); + }); + + it('announces the error', () => { + validate(); + + liveRegion().should('have.text', 'This field is required'); + }); + + it('clears every error affordance once a value is selected', () => { + validate(); + wrapper().should('have.attr', 'aria-invalid', 'true'); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o2"]').click(); + + wrapper().should('not.have.attr', 'aria-invalid'); + wrapper().should('not.have.attr', 'aria-describedby'); + errorMessage().should('not.be.visible'); + }); + + it('honours a custom required message for localisation', () => { + mount({ required: true, requiredErrorText: 'Pflichtfeld' }); + + validate(); + + errorMessage().should('have.text', 'Pflichtfeld'); + }); + }); + + context('minValues', () => { + it('reports how many options are still needed', () => { + mount({ multiple: true, required: true, minValues: 3 }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + validate(); + + errorMessage().should('have.text', 'Select at least 3 options'); + wrapper().should('have.attr', 'aria-invalid', 'true'); + }); + + it('clears once enough options are selected', () => { + mount({ multiple: true, required: true, minValues: 2 }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + validate(); + wrapper().should('have.attr', 'aria-invalid', 'true'); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o2"]').click(); + + wrapper().should('not.have.attr', 'aria-invalid'); + }); + }); + + context('disableValidation', () => { + it('leaves aria-invalid and the message alone', () => { + mount({ required: true, disableValidation: true }); + + validate(); + + wrapper().should('not.have.attr', 'aria-invalid'); + errorMessage().should('not.be.visible'); + }); + }); + + /** + * The message has to survive the interaction that produced it. + * + * setValue() validates and then announces the selection summary, both in the same tick. A + * polite live region is read from its *final* content, so the summary silently replaced the + * validation message: the region said "No options selected" while aria-invalid was true and + * the visible message said "This field is required". On every interactive path - the clear + * button, or deselecting below minValues - the error was therefore shown but never spoken, + * which is the 3.3.1 failure this work set out to fix. + * + * Only the direct validate() call was covered before, and that path announces correctly. + */ + context('the announcement survives the interaction', () => { + it('keeps the required message after the clear button empties the field', () => { + mount({ required: true }); + + cy.get(`#${mountId}`).then(($e) => $e[0].setValue?.(['o1'])); + cy.get(`#${mountId}`).find('.vscomp-clear-button').click(); + + wrapper().should('have.attr', 'aria-invalid', 'true'); + errorMessage().should('have.text', 'This field is required'); + liveRegion().should('have.text', 'This field is required'); + }); + + it('keeps the minValues message after deselecting below the minimum', () => { + mount({ multiple: true, required: true, minValues: 2 }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o2"]').click(); + wrapper().should('not.have.attr', 'aria-invalid'); + + // back under the minimum: the message appears, and must also be the thing announced + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o2"]').click(); + + wrapper().should('have.attr', 'aria-invalid', 'true'); + liveRegion().should('have.text', 'Select at least 2 options'); + }); + + it('still announces the selection summary when validation passes', () => { + mount({ required: true }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + wrapper().should('not.have.attr', 'aria-invalid'); + liveRegion().should('have.text', 'Option 1 selected'); + }); + }); + + /** + * The region must stay silent for anything the user did not do. + * + * `setErrorMessage()` announces unconditionally, and it is reached during construction — the + * initial `setValueMethod()` runs before `isInitialized` is set — and again whenever + * `setOptions()` replaces the data, because `afterSetOptions()` calls `reset()`, which validates. + * So a page could load already speaking a validation error, and a background data refresh could + * speak one for a field the user has never touched. A live region is for status *changes* the + * user caused; announcing on load is the noise the `isInitialized` guard elsewhere exists to + * avoid. + * + * The cases above must keep passing: the point is to silence construction and programmatic + * refreshes *without* silencing the interactive paths. + */ + context('nothing is announced for interactions the user did not make', () => { + it('stays silent when an invalid initial value is supplied', () => { + mount({ multiple: true, required: true, minValues: 2, selectedValue: ['o1'] }); + + // The state is still exposed visually and to AT - it is only the announcement that waits. + wrapper().should('have.attr', 'aria-invalid', 'true'); + errorMessage().should('have.text', 'Select at least 2 options'); + liveRegion().should('have.text', ''); + }); + + it('stays silent when setOptions() replaces the data', () => { + mount({ required: true }); + + liveRegion().should('have.text', ''); + + cy.get(`#${mountId}`).then(($e) => + $e[0].setOptions?.([ + { label: 'New 1', value: 'n1' }, + { label: 'New 2', value: 'n2' }, + ]), + ); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="n1"]').should('exist'); + liveRegion().should('have.text', ''); + }); + + it('still announces once the user interacts after a refresh', () => { + mount({ required: true }); + + cy.get(`#${mountId}`).then(($e) => $e[0].setOptions?.([{ label: 'New 1', value: 'n1' }])); + liveRegion().should('have.text', ''); + + // the suppression must be scoped to the refresh, not sticky + validate(); + + liveRegion().should('have.text', 'This field is required'); + }); + }); + + /** + * A native form reset must clear the error state, not just its colour. + * + * reset(formReset = true) is the handler for the form's own reset event. It removed the + * `has-error` class but left aria-invalid="true" and aria-describedby pointing at an error + * element that still held its text - so the control stayed announced as invalid, describing a + * message the user could no longer see, with no way to clear it. toggleRequired(false) was + * updated for this; reset() was not. + */ + context('form reset clears the whole error state', () => { + it('drops aria-invalid, aria-describedby and the message text', () => { + mount({ required: true }); + + validate(); + wrapper().should('have.attr', 'aria-invalid', 'true'); + wrapper().should('have.attr', 'aria-describedby'); + + cy.get(`#${mountId}`).then(($e) => $e[0].reset?.(true)); + + wrapper().should('not.have.class', 'has-error'); + wrapper().should('not.have.attr', 'aria-invalid'); + wrapper().should('not.have.attr', 'aria-describedby'); + errorMessage().should('have.text', ''); + }); + }); +}); diff --git a/cypress/e2e/a11y-search-arrow-navigation.cy.ts b/cypress/e2e/a11y-search-arrow-navigation.cy.ts new file mode 100644 index 00000000..05e6f91d --- /dev/null +++ b/cypress/e2e/a11y-search-arrow-navigation.cy.ts @@ -0,0 +1,237 @@ +/** cSpell:ignore vscomp activedescendant combobox autocomplete */ + +/** + * The arrow keys must navigate the option list from the search input, and the highlighted + * option must be announced. + * + * WCAG 2.1.1 Keyboard (A) and 4.1.2 Name, Role, Value (A). + * + * Opening the dropdown puts focus in the search input, and both arrow handlers + * early-returned in that state, so ArrowDown/ArrowUp did nothing at all: no option was + * highlighted and nothing was announced. Users had to discover an undocumented Tab into + * the listbox first. + * + * The highlight was also published as aria-activedescendant on the wrapper and on the + * role-less $dropboxContainer, never on the element that actually had focus, so even when + * navigation did work the active option was not conveyed. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +/** + * testIsolation is disabled project-wide, but these cases repeatedly open, filter and close + * a dropdown while asserting on focus. Leftover focus and pending re-renders from a previous + * case leak across tests and make the focus assertions flaky for reasons unrelated to the + * component, so this spec asks for a clean page per test. + */ +describe('A11y: arrow-key navigation from the search input', { testIsolation: true }, () => { + const mountId = 'vs-a11y-search-arrows'; + + const searchInput = () => cy.get(`#${mountId}`).find('.vscomp-search-input'); + const wrapper = () => cy.get(`#${mountId}`).find('.vscomp-wrapper'); + const listbox = () => cy.get(`#${mountId}`).find('.vscomp-options-container'); + const focusedOption = () => cy.get(`#${mountId}`).find('.vscomp-option.focused'); + + /** aria-activedescendant on the search input must name the highlighted option. */ + const assertActiveDescendantMatchesHighlight = () => { + focusedOption() + .invoke('attr', 'id') + .then((optionId) => { + expect(optionId, 'highlighted option has an id').to.be.a('string'); + searchInput().should('have.attr', 'aria-activedescendant', optionId); + }); + }; + + const mount = (extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5), search: true, ...extra })); + }; + + const open = () => { + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + searchInput().focus(); + }; + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + context('search input semantics', () => { + beforeEach(() => { + mount(); + open(); + }); + + it('drives the listbox as a plain textbox, not a second combobox', () => { + // The wrapper is already the combobox; a combobox nested inside a combobox is the + // kind of structure screen readers disagree on. The input keeps the wiring — + // aria-autocomplete, aria-controls, aria-activedescendant — on its implicit + // textbox role, which supports all three. + searchInput().should('not.have.attr', 'role'); + searchInput().should('have.attr', 'aria-autocomplete', 'list'); + + listbox() + .invoke('attr', 'id') + .then((listboxId) => { + expect(listboxId, 'listbox has an id to point at').to.be.a('string'); + searchInput().should('have.attr', 'aria-controls', listboxId); + }); + }); + + it('leaves the expanded state to the combobox wrapper', () => { + // aria-expanded is not a supported property of a textbox; the wrapper combobox + // is the single element that reports it. + searchInput().should('not.have.attr', 'aria-expanded'); + wrapper().should('have.attr', 'aria-expanded', 'true'); + + searchInput().type('{esc}'); + + wrapper().should('have.attr', 'aria-expanded', 'false'); + searchInput().should('not.have.attr', 'aria-expanded'); + }); + + it('does not publish the highlight on the role-less dropbox container', () => { + searchInput().type('{downarrow}'); + + // aria-activedescendant on an element with no listbox/combobox role is meaningless. + cy.get(`#${mountId}`).find('.vscomp-dropbox-container').should('not.have.attr', 'aria-activedescendant'); + }); + }); + + context('keepAlwaysOpen', () => { + /** + * The second combobox could not keep its state in sync in this layout: `aria-expanded` was + * rendered hard-coded as `false` on the input and only ever updated inside the non-silent + * branches of openDropbox()/closeDropbox(), neither of which runs when the dropbox is always + * open. The input therefore reported the listbox collapsed while it was visible and + * navigable, contradicting the wrapper on the same listbox (WCAG 4.1.2). + * + * Having one carrier of the state removes the class of bug rather than re-syncing it. + */ + it('reports one expanded state for the listbox, on the wrapper alone', () => { + mount({ keepAlwaysOpen: true }); + + wrapper().should('have.attr', 'aria-expanded', 'true'); + searchInput().should('not.have.attr', 'aria-expanded'); + }); + }); + + context('ArrowDown / ArrowUp', () => { + beforeEach(() => { + mount(); + open(); + }); + + it('highlights the first option on ArrowDown and announces it', () => { + searchInput().type('{downarrow}'); + + focusedOption().should('have.attr', 'data-value', 'o1'); + assertActiveDescendantMatchesHighlight(); + }); + + it('keeps DOM focus in the search input, so typing continues to work', () => { + searchInput().type('{downarrow}'); + + // The point of driving the highlight by aria-activedescendant: focus never leaves + // the field, so the user can keep filtering. + cy.focused().should('have.class', 'vscomp-search-input'); + + // Deliberately a single character. Each keystroke re-renders the option list and can + // trigger a scroll-driven re-render on top of that, which Cypress's batched typing + // outruns - a multi-character type() here drops characters and makes the test flaky + // for reasons that have nothing to do with the component. One character proves the + // contract: the field still receives input and the highlight follows the filtered set. + searchInput().type('4'); + + searchInput().should('have.value', '4'); + cy.focused().should('have.class', 'vscomp-search-input'); + focusedOption().should('have.attr', 'data-value', 'o4'); + }); + + it('moves down through the options', () => { + searchInput().type('{downarrow}'); + focusedOption().should('have.attr', 'data-value', 'o1'); + + searchInput().type('{downarrow}'); + focusedOption().should('have.attr', 'data-value', 'o2'); + assertActiveDescendantMatchesHighlight(); + + searchInput().type('{downarrow}'); + focusedOption().should('have.attr', 'data-value', 'o3'); + }); + + it('moves back up through the options', () => { + searchInput().type('{downarrow}{downarrow}{downarrow}'); + focusedOption().should('have.attr', 'data-value', 'o3'); + + searchInput().type('{uparrow}'); + + focusedOption().should('have.attr', 'data-value', 'o2'); + assertActiveDescendantMatchesHighlight(); + }); + + it('selects the highlighted option with Enter', () => { + searchInput().type('{downarrow}{downarrow}'); + focusedOption().should('have.attr', 'data-value', 'o2'); + + searchInput().type('{enter}'); + + cy.get(`#${mountId}`).then(($e) => { + expect($e[0].virtualSelect.selectedValues).to.deep.equal(['o2']); + }); + }); + + it('tracks the filtered set after typing', () => { + searchInput().type('Option 4'); + + searchInput().type('{downarrow}'); + + focusedOption().should('have.attr', 'data-value', 'o4'); + assertActiveDescendantMatchesHighlight(); + }); + + it('clears aria-activedescendant when the dropdown closes', () => { + searchInput().type('{downarrow}'); + searchInput().should('have.attr', 'aria-activedescendant').and('not.be.empty'); + + searchInput().type('{esc}'); + + cy.get(`#${mountId}`) + .find('.vscomp-search-input') + .should(($input) => { + expect($input.attr('aria-activedescendant') || '').to.equal(''); + }); + }); + }); + + context('multi-select', () => { + it('navigates and selects without closing the dropdown', () => { + mount({ multiple: true }); + open(); + + searchInput().type('{downarrow}'); + focusedOption().should('have.attr', 'data-value', 'o1'); + searchInput().type('{enter}'); + + searchInput().type('{downarrow}'); + searchInput().type('{enter}'); + + cy.get(`#${mountId}`).then(($e) => { + expect($e[0].virtualSelect.selectedValues).to.have.members(['o1', 'o2']); + }); + wrapper().should('have.attr', 'aria-expanded', 'true'); + }); + }); + + context('keyboard navigation without a search input', () => { + it('still works when search is disabled', () => { + mount({ search: false }); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + cy.get(`#${mountId}`).find('.vscomp-wrapper').trigger('keydown', { keyCode: 40, which: 40 }); + + focusedOption().should('exist'); + }); + }); +}); diff --git a/cypress/e2e/a11y-select-all.cy.ts b/cypress/e2e/a11y-select-all.cy.ts new file mode 100644 index 00000000..3a5fc8ae --- /dev/null +++ b/cypress/e2e/a11y-select-all.cy.ts @@ -0,0 +1,103 @@ +/** cSpell:ignore vscomp */ + +/** + * The Select All control must behave and be announced as a checkbox. + * + * WCAG 4.1.2 Name/Role/Value (A), 1.3.1 Info and Relationships (A), 2.1.1 Keyboard (A). + * + * "Select All" was a bare : exposed to assistive + * technology as a generic element with no role and no checked state, so its state changes were + * inaudible. It also only responded to Enter — Space, the expected activation key for a + * checkbox, scrolled the page instead. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('A11y: Select All exposes checkbox semantics', () => { + const mountId = 'vs-a11y-select-all'; + + const toggleAll = () => cy.get(`#${mountId}`).find('.vscomp-toggle-all-button'); + const selectedCount = () => cy.get(`#${mountId}`).then(($e) => $e[0].virtualSelect.selectedValues.length); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5), multiple: true, search: true })); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + }); + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('exposes role="checkbox" with an accessible name', () => { + toggleAll().should('have.attr', 'role', 'checkbox'); + toggleAll().should('have.attr', 'aria-label', 'Select All'); + }); + + it('starts unchecked and reports aria-checked="true" once everything is selected', () => { + toggleAll().should('have.attr', 'aria-checked', 'false'); + + toggleAll().click(); + + toggleAll().should('have.attr', 'aria-checked', 'true'); + selectedCount().should('eq', 5); + }); + + it('returns aria-checked to "false" when toggled back off', () => { + toggleAll().click(); + toggleAll().should('have.attr', 'aria-checked', 'true'); + + toggleAll().click(); + + toggleAll().should('have.attr', 'aria-checked', 'false'); + selectedCount().should('eq', 0); + }); + + it('activates on Space, the expected key for a checkbox', () => { + toggleAll().focus().type(' ', { force: true }); + + selectedCount().should('eq', 5); + toggleAll().should('have.attr', 'aria-checked', 'true'); + }); + + it('still activates on Enter', () => { + toggleAll().focus().type('{enter}', { force: true }); + + selectedCount().should('eq', 5); + toggleAll().should('have.attr', 'aria-checked', 'true'); + }); + + it('calls preventDefault on Space so the page does not scroll', () => { + // Synthetic keys never trigger native scrolling, so asserting scrollY would prove nothing. + // The observable contract is that the component consumed the key. + const prevented: boolean[] = []; + + cy.window().then((win) => { + // Bubble phase on document: runs after the component's wrapper handler, so + // defaultPrevented already reflects whatever that handler decided. + win.document.addEventListener('keydown', (e) => { + if (e.keyCode === 32) { + prevented.push(e.defaultPrevented); + } + }); + }); + + toggleAll().focus().type(' ', { force: true }); + + cy.wrap(null).should(() => { + expect(prevented, 'Space keydown seen and defaultPrevented').to.deep.equal([true]); + }); + }); + + it('reflects aria-checked when selection is driven from the options instead', () => { + toggleAll().should('have.attr', 'aria-checked', 'false'); + + // Select every option individually; Select All must follow along. + makeOptions(5).forEach((o) => { + cy.get(`#${mountId}`).find(`.vscomp-option[data-value="${o.value}"]`).click(); + }); + + toggleAll().should('have.attr', 'aria-checked', 'true'); + }); +}); diff --git a/cypress/e2e/a11y-target-size.cy.ts b/cypress/e2e/a11y-target-size.cy.ts new file mode 100644 index 00000000..eb004592 --- /dev/null +++ b/cypress/e2e/a11y-target-size.cy.ts @@ -0,0 +1,98 @@ +/** cSpell:ignore vscomp */ + +/** + * Every pointer target must be at least 24x24 CSS px. + * + * WCAG 2.5.8 Target Size (Minimum), AA. + * + * Two controls were smaller than the 24x24 CSS px minimum: the "Select All" checkbox + * (measured 25x15) and the per-tag clear button (20x20). Both are pointer targets, so + * users with limited dexterity had to hit a target under half the required area. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +const MIN_TARGET = 24; + +describe('A11y: pointer targets meet the 24x24 minimum', () => { + const mountId = 'vs-a11y-target-size'; + + /** + * Assert both dimensions of the first match are at least 24 CSS px. + * + * getBoundingClientRect() returns fractional values (a 24px box can measure + * 23.999998 under device-pixel rounding), so compare on rounded values - otherwise the + * assertion fails on sub-pixel noise rather than on a real target-size problem. + */ + const assertMinTarget = (selector: string, label: string) => { + cy.get(`#${mountId}`) + .find(selector) + .first() + .then(($el) => { + const rect = $el[0].getBoundingClientRect(); + + expect(Math.round(rect.width), `${label} width`).to.be.at.least(MIN_TARGET); + expect(Math.round(rect.height), `${label} height`).to.be.at.least(MIN_TARGET); + }); + }; + + const mount = (extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(5), ...extra })); + }; + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('gives the Select All checkbox a large enough target', () => { + mount({ multiple: true, search: true }); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + assertMinTarget('.vscomp-toggle-all-button', 'Select All button'); + }); + + it('gives the Select All checkbox a large enough target without a search input', () => { + mount({ multiple: true, search: false }); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + assertMinTarget('.vscomp-toggle-all-button', 'Select All button (no search)'); + }); + + it('gives each tag clear button a large enough target', () => { + mount({ multiple: true, showValueAsTags: true, search: true }); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + cy.get(`#${mountId}`).find('.vscomp-value-tag-clear-button').should('exist'); + assertMinTarget('.vscomp-value-tag-clear-button', 'tag clear button'); + }); + + it('keeps the tag clear button usable: it still removes its tag', () => { + mount({ multiple: true, showValueAsTags: true, search: true }); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o2"]').click(); + cy.get(`#${mountId}`).find('.vscomp-value-tag').should('have.length', 2); + + cy.get(`#${mountId}`).find('.vscomp-value-tag-clear-button').first().click(); + + cy.get(`#${mountId}`).find('.vscomp-value-tag').should('have.length', 1); + }); + + it('does not enlarge the tag itself beyond its content', () => { + // The target grows, the visual tag should stay compact - a regression here would mean + // the fix leaked into layout rather than the hit area. + mount({ multiple: true, showValueAsTags: true, search: true }); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + cy.get(`#${mountId}`) + .find('.vscomp-value-tag') + .first() + .then(($tag) => { + expect($tag[0].getBoundingClientRect().height, 'tag height').to.be.lessThan(40); + }); + }); +}); diff --git a/cypress/e2e/examples.cy.ts b/cypress/e2e/examples.cy.ts index 08f8f2b3..122b8932 100644 --- a/cypress/e2e/examples.cy.ts +++ b/cypress/e2e/examples.cy.ts @@ -62,7 +62,12 @@ describe('Accessibility attributes - virtualized options metadata', () => { const id = 'single-select'; it('exposes total list size and sequential positions without search', () => { - cy.open(id); + /** + * openFresh() because this case reads `.first()` with no search to reset the scroll + * offset, and on a virtualised 100k list the first *rendered* option is whatever + * `scrollTop` happens to be pointing at. + */ + cy.openFresh(id); cy.getDropbox(null, id) .find('[role="option"][aria-setsize]') @@ -153,7 +158,13 @@ describe('Accessibility attributes - virtualized options metadata', () => { }); it('has proper ARIA attributes on listbox and options container for screen reader navigation', () => { - cy.open(id); + /** + * openFresh() rather than cy.open(): this case reads options by DOM position + * (`.first()`, `.eq(1)`), and on a virtualised list of 100k those positions depend on + * `scrollTop`. openFresh pins the scroll position and asserts no option is already + * highlighted, so "one press reaches the first option" holds regardless of test order. + */ + cy.openFresh(id); // Cache references to relevant elements for repeated assertions cy.getDropbox(null, id) @@ -161,10 +172,19 @@ describe('Accessibility attributes - virtualized options metadata', () => { .should('exist') .as('listboxContainer'); + // aria-activedescendant belongs on the element that holds focus and has a role that + // supports it - here the role="combobox" wrapper. It used to be asserted on + // .vscomp-dropbox-container, a plain div with no role, where the attribute is + // meaningless. That container is now checked for its *absence* below. + cy.getVs(id) + .find('.vscomp-ele-wrapper') + .should('exist') + .as('activeDescendantHost'); + cy.getDropbox(null, id) .parent('.vscomp-dropbox-container') .should('exist') - .as('listboxRegion'); + .as('roleLessContainer'); // Get the combobox wrapper ID for reference cy.getVs(id) @@ -179,9 +199,12 @@ describe('Accessibility attributes - virtualized options metadata', () => { .should('have.attr', 'aria-labelledby', comboboxId); }); - // Navigate to first option using keyboard (Down arrow from combobox) - cy.getVs(id).find('.vscomp-ele-wrapper').type('{downarrow}'); - cy.wait(100); // Wait for focus to update + /** + * pressKeys() sends a real key press to the search input rather than chaining .type() + * onto a node. The fixed cy.wait() it replaces was redundant anyway - the .should() + * below retries - and a fixed wait cannot fix the underlying race. + */ + cy.getVs(id).pressKeys('ArrowDown'); // Get first option and verify it's focused cy.getDropbox(null, id) @@ -195,15 +218,24 @@ describe('Accessibility attributes - virtualized options metadata', () => { .invoke('attr', 'id') .as('firstOptionId'); - // Verify aria-activedescendant is set on listbox container when option is focused + // Verify aria-activedescendant names the focused option on the combobox cy.get('@firstOptionId').then((firstOptionId) => { - cy.get('@listboxRegion') + cy.get('@activeDescendantHost') .should('have.attr', 'aria-activedescendant', firstOptionId); }); - // Navigate to second option using arrow key - cy.get('@firstOption').type('{downarrow}'); - cy.wait(100); // Wait for focus to update + // ...and is not published on the role-less container, which has no role to carry it + cy.get('@roleLessContainer').should('not.have.attr', 'aria-activedescendant'); + + /** + * This is the press that made the case flaky. It used to be `cy.get('@firstOption') + * .type('{downarrow}')` - chained onto the option node aliased above, which the + * virtualiser replaces on every render. By the time .type() ran, that node could already + * be detached, so the keydown never reached a handler, the highlight never advanced, and + * the assertion below timed out with "expected to have class focused". + * Observed failing about 1 run in 5, more often under CPU load. + */ + cy.getVs(id).pressKeys('ArrowDown'); // Get second option cy.getDropbox(null, id) @@ -219,7 +251,7 @@ describe('Accessibility attributes - virtualized options metadata', () => { // Verify aria-activedescendant updates to second option cy.get('@secondOptionId').then((secondOptionId) => { - cy.get('@listboxRegion') + cy.get('@activeDescendantHost') .should('have.attr', 'aria-activedescendant', secondOptionId); }); @@ -230,31 +262,41 @@ describe('Accessibility attributes - virtualized options metadata', () => { /** - * Arrow key behavior tests for search input - * Tests the fix that allows normal cursor movement in search input - * while preserving option navigation when focus moves away from search + * Arrow key behavior tests for search input. + * + * Up/Down in the search input navigate the option list (WAI-ARIA APG editable-combobox), + * They used to be swallowed while the search input had focus, so no option could ever be + * highlighted from the keyboard - a WCAG 2.1.1 (A) failure. Caret movement in the field is + * served by Left/Right and Home/End, covered by the suites below. */ describe('Arrow key behavior in search input - cursor movement', () => { const idMultiple = 'multiple-select' - it('should allow cursor movement to beginning with up arrow in search input', () => { - cy.open(idMultiple); + it('moves the caret to the beginning with Home, and navigates options with Up arrow', () => { + /** openFresh(): this suite drives the caret and the option highlight by key press, so it + * must start from a known open state with nothing already highlighted. */ + cy.openFresh(idMultiple); // Type some text in search input cy.getVs(idMultiple).typeValue('ption 9', true); - // Press Up arrow - should move cursor to beginning - cy.getVs(idMultiple).pressKeys('ArrowUp'); + // Home moves the caret to the beginning (Up arrow now drives the option list instead) + cy.getVs(idMultiple).pressKeys('Home'); // Type 'O' at cursor position (should be at beginning) cy.getVs(idMultiple).typeValue('O'); // Verify the text has 'O' at the beginning cy.getVs(idMultiple).checkOptionLabelExists('Option 9'); + + // Up/Down highlight an option without taking focus out of the field + cy.getVs(idMultiple).pressKeys('ArrowDown'); + cy.getDropbox(null, idMultiple).find('.vscomp-option.focused').should('exist'); + cy.checkActiveElementHasClass('vscomp-search-input'); }); - it('should allow cursor movement to end with down arrow in search input', () => { - // Clear and test Down arrow - use actual dropdown data + it('moves the caret to the end with End key in search input', () => { + // Clear and test End - use actual dropdown data cy.getVs(idMultiple).typeValue('Option 1', true); - // Press Down arrow - should move cursor to end - cy.getVs(idMultiple).pressKeys('ArrowDown'); + // End moves the caret to the end (Down arrow now drives the option list instead) + cy.getVs(idMultiple).pressKeys('End'); // Type '0' at cursor position (should be at end, making "Option 10") cy.getVs(idMultiple).typeValue('0'); // Verify the text has '0' at the end @@ -284,26 +326,73 @@ describe('Arrow key behavior in search input - cursor movement', () => { }); }); -describe('Arrow key behavior - no option navigation when search input focused', () => { +describe('Arrow key behavior - option navigation from the search input keeps focus in the field', () => { const idMultiple = 'multiple-select' - const searchInputSelector = '.vscomp-search-input'; - it('should not navigate options when arrow keys used in search input', () => { - cy.open(idMultiple); - // Type in search input - use text that will filter to a few options + /** + * This branch deliberately made Up/Down navigate options *from* the search input + * (`04abbe9`, `navigateOptions()`), and the other suites were updated for it - this one was not. + * It was called "no option navigation when search input focused" and asserted only that DOM + * focus stayed put, which is still true, so it passed while documenting the contract that had + * been removed on purpose. It also could not tell "navigates without stealing focus" from + * "does nothing at all". + * + * What is pinned now, all three measured against the built bundle: + * - the highlight moves on Down and moves back on Up; + * - DOM focus never leaves the field, so it stays typeable; + * - `aria-activedescendant` follows the highlight, which is what makes the navigation + * perceivable to a screen reader while focus stays in the input (WCAG 4.1.2). + * + * `cy.realPress()` rather than `cy.pressKeys()`: the latter focuses the field before pressing, + * which would make the focus assertion self-fulfilling. Focus is already in the field here, + * left there by `typeValue()`. + */ + it('navigates options with the arrow keys without moving focus out of the search input', () => { + /** openFresh(): this suite drives the caret and the option highlight by key press, so it + * must start from a known open state with nothing already highlighted. */ + cy.openFresh(idMultiple); + // Text that filters to more than one option, so there is somewhere to navigate to. cy.getVs(idMultiple).typeValue('Option 1', true); - // Wait for filtering to complete - cy.wait(100); - // Verify search input is focused - cy.checkActiveElementHasClass('vscomp-search-input'); - // Press Down arrow while focused on search input - cy.getVs(idMultiple).pressKeys('ArrowDown'); - // Search input should still be focused (arrow key should move cursor, not navigate options) + // The filter is applied synchronously on input, so the field's value is the gate to wait on. + cy.getDropbox(null, idMultiple).find('.vscomp-search-input').should('have.value', 'Option 1'); + cy.getDropbox(null, idMultiple).find('.vscomp-option').should('have.length.greaterThan', 1); cy.checkActiveElementHasClass('vscomp-search-input'); - // Press Up arrow while focused on search input - cy.getVs(idMultiple).pressKeys('ArrowUp'); - // Search input should still be focused + + cy.realPress('ArrowDown'); + + // Focus stays in the field - and an option is now highlighted, which is the half the old + // version of this case never checked. cy.checkActiveElementHasClass('vscomp-search-input'); + cy.getVs(idMultiple).should(($ele) => { + const vs = $ele[0].virtualSelect; + const $focused = vs.$dropbox.querySelector('.vscomp-option.focused'); + + expect($focused, 'an option is highlighted').to.not.equal(null); + expect( + vs.$searchInput.getAttribute('aria-activedescendant'), + 'aria-activedescendant follows the highlight', + ).to.equal($focused.id); + }); + + // Down moves on, Up comes back: neither key is a no-op, and neither takes focus with it. + cy.getDropbox(null, idMultiple) + .find('.vscomp-option.focused') + .invoke('attr', 'id') + .then((afterFirstDown) => { + cy.realPress('ArrowDown'); + cy.getDropbox(null, idMultiple) + .find('.vscomp-option.focused') + .invoke('attr', 'id') + .should('not.equal', afterFirstDown); + + cy.realPress('ArrowUp'); + cy.getDropbox(null, idMultiple) + .find('.vscomp-option.focused') + .invoke('attr', 'id') + .should('equal', afterFirstDown); + + cy.checkActiveElementHasClass('vscomp-search-input'); + }); }); it('should close multiple-select dropdown', () => { @@ -316,7 +405,9 @@ describe('Arrow key behavior - Home and End keys in search input', () => { const idMultiple = 'multiple-select' it('should work correctly with Home and End keys in search input', () => { - cy.open(idMultiple); + /** openFresh(): this suite drives the caret and the option highlight by key press, so it + * must start from a known open state with nothing already highlighted. */ + cy.openFresh(idMultiple); // Type some text using search to ensure dropdown is properly opened cy.getVs(idMultiple).typeValue('ption 55', true); // Press Home to go to beginning @@ -343,18 +434,24 @@ describe('Arrow key behavior - Home and End keys in search input', () => { describe('Arrow key behavior - focus management and accessibility', () => { const idMultiple = 'multiple-select' - it('should allow normal text editing with arrow keys in search', () => { - cy.open(idMultiple); + it('should allow normal text editing in search while arrows navigate the list', () => { + /** openFresh(): this suite drives the caret and the option highlight by key press, so it + * must start from a known open state with nothing already highlighted. */ + cy.openFresh(idMultiple); // Clear and test more text editing using realistic data cy.getVs(idMultiple).typeValue('tion 123', true); - // Use Up arrow to go to beginning - cy.getVs(idMultiple).pressKeys('ArrowUp'); + // Home goes to the beginning; Up/Down are option navigation + cy.getVs(idMultiple).pressKeys('Home'); cy.getVs(idMultiple).typeValue('Op'); cy.getVs(idMultiple).checkOptionLabelExists('Option 123'); - // Use Down arrow to go to end - cy.getVs(idMultiple).pressKeys('ArrowDown'); + // End goes back to the end + cy.getVs(idMultiple).pressKeys('End'); cy.getVs(idMultiple).typeValue('44'); cy.getVs(idMultiple).checkOptionLabelExists('Option 12344'); + + // Editing stays possible because navigation never moves DOM focus off the input + cy.getVs(idMultiple).pressKeys('ArrowDown'); + cy.checkActiveElementHasClass('vscomp-search-input'); }); it('should close multiple-select dropdown', () => { @@ -545,15 +642,12 @@ describe('Option group', () => { }); it('includes group title in keyboard navigation and exposes it to assistive technologies', () => { - cy.getVs(id).then(($vs) => { - const vs = $vs[0].virtualSelect; - vs.reset(false, true); - }); - - cy.open(id); + /** openFresh() guarantees closed -> open with no highlight, so one press is one press + * regardless of what the previous test left behind. */ + cy.openFresh(id); - cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); - cy.getVs(id).find('.vscomp-wrapper').should('not.have.class', 'closed'); + // One press reaches the group title. This needed two while the first ArrowDown was + // still being swallowed by the focused search input. cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); cy.getDropbox(null, id) @@ -570,14 +664,9 @@ describe('Option group', () => { }); it('activates group select/deselect with Enter when group title is focused', () => { - cy.getVs(id).then(($vs) => { - const vs = $vs[0].virtualSelect; - vs.reset(false, true); - }); - - cy.open(id); + cy.openFresh(id); - cy.getVs(id).pressKeys(['ArrowDown', 'ArrowDown']); + cy.getVs(id).pressKeys('ArrowDown'); cy.getVs(id).pressKeys('Enter'); cy.getVs(id).hasValueText('3 options selected'); cy.getVs(id).pressKeys('Enter'); @@ -585,79 +674,68 @@ describe('Option group', () => { }); it('navigates between group title and group options with arrow keys', () => { - cy.getVs(id).then(($vs) => { - const vs = $vs[0].virtualSelect; - vs.reset(false, true); - }); - - cy.open(id); + cy.openFresh(id); cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); cy.getDropbox(null, id) .find('.vscomp-option.group-title') .first() - .as('groupTitle') .should('have.class', 'focused') - .should('have.attr', 'tabindex', '0') - .type('{downarrow}'); + .should('have.attr', 'tabindex', '0'); - cy.getDropbox(null, id) - .find('.vscomp-option.focused') - .should('have.class', 'group-option') - .type('{uparrow}'); + /** pressKeys() and a fresh query per assertion, never .type() chained onto an option node + * or an alias for one: the virtualiser replaces those nodes on every render, so the + * keystroke can hit a node that is no longer in the document - and a detached node keeps + * its classes, so the assertion that follows would pass without proving anything. */ + cy.getVs(id).pressKeys('ArrowDown'); + cy.getDropbox(null, id).find('.vscomp-option.focused').should('have.class', 'group-option'); - cy.get('@groupTitle').should('have.class', 'focused'); + cy.getVs(id).pressKeys('ArrowUp'); + cy.getDropbox(null, id).find('.vscomp-option.group-title').first().should('have.class', 'focused'); }); it('opens dropdown and selects a group child option using keyboard only', () => { - cy.getVs(id).then(($vs) => { - const vs = $vs[0].virtualSelect; - vs.reset(false, true); - }); - - cy.open(id); + cy.openFresh(id); - cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); - cy.getDropbox(null, id) - .find('.vscomp-option.group-title') - .first() - .should('have.class', 'focused') - .type('{downarrow}'); + cy.getDropbox(null, id).find('.vscomp-option.group-title').first().should('have.class', 'focused'); - cy.getDropbox(null, id) - .find('.vscomp-option[data-value="1-1"]') - .should('have.class', 'focused') - .type('{enter}'); + /** pressKeys() sends a real key press to the search input rather than chaining .type() + * onto an option node: the virtualiser replaces those nodes on every render, which + * fails the command with "the page updated while this command was executing". */ + cy.getVs(id).pressKeys('ArrowDown'); + cy.getDropbox(null, id).find('.vscomp-option[data-value="1-1"]').should('have.class', 'focused'); + + cy.getVs(id).pressKeys('Enter'); cy.getVs(id).hasValueText('Option 1-1'); }); it('keeps focus on the last option when navigating past the end of the list', () => { - cy.getVs(id).then(($vs) => { - const vs = $vs[0].virtualSelect; - vs.reset(false, true); - }); + cy.openFresh(id); - cy.open(id); - - cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); cy.getVs(id).find('.vscomp-wrapper').type('{downarrow}'); - Cypress._.times(11, () => { - cy.getDropbox(null, id).find('.vscomp-option.focused').type('{downarrow}'); + /** Deliberately more presses than there are rows: navigation clamps at the end, which + * is the "navigating past the end" case under test, and this avoids hard-coding a count + * that shifts whenever the demo's option list changes. + * + * pressKeys() rather than .type() on `.focused`: that chained the keystroke onto a + * virtualised node re-queried a moment earlier, giving the virtualiser 20 chances per run to + * replace it in between - the pattern removed from four other cases in `9d7d35c`. */ + Cypress._.times(20, () => { + cy.getVs(id).pressKeys('ArrowDown'); }); - cy.getDropbox(null, id) - .find('.vscomp-option.group-option') - .last() - .as('lastOption') - .should('have.class', 'focused'); + cy.getDropbox(null, id).find('.vscomp-option.group-option').last().should('have.class', 'focused'); - cy.get('@lastOption').type('{downarrow}'); - cy.get('@lastOption').should('have.class', 'focused'); + /** re-queried instead of aliased, for the same reason: an alias captured before the press + * can point at a node the virtualiser has since replaced, and a detached node still carries + * `focused` - so the clamp would look verified when nothing had been checked. */ + cy.getVs(id).pressKeys('ArrowDown'); + cy.getDropbox(null, id).find('.vscomp-option.group-option').last().should('have.class', 'focused'); }); }); @@ -828,7 +906,9 @@ describe('Label with description', () => { }); it('has description on load', () => { - cy.open(id).checkFirstOption('Option 1 Description 1'); + /** openFresh() pins scrollTop: checkFirstOption() reads `.first()`, and there is no search + * here to scroll the list back to the top. */ + cy.openFresh(id).checkFirstOption('Option 1 Description 1'); }); it('has description on scroll', () => { @@ -1370,7 +1450,22 @@ describe('Add image/icon', () => { }); it('has flag icon on selected item', () => { - cy.open(id).selectOption(16).hasSelectedFlagIcon(); + /** + * cy.open() is a click, i.e. a toggle. The preceding case leaves this dropdown open, so + * clicking here closed it and the option click then landed on a dropbox with + * `display: none`. Open only when actually closed, and re-establish the scroll position + * so option 16 is rendered whether or not the preceding case ran. + */ + cy.getVs(id).then(($e) => { + const vs = $e[0].virtualSelect; + + if (!vs.isOpened()) { + vs.openDropbox(); + } + }); + cy.getVs(id).find('.vscomp-wrapper').should('not.have.class', 'closed'); + + cy.getVs(id).scrollOptions(700).selectOption(16).hasSelectedFlagIcon(); }); }); diff --git a/cypress/e2e/perf-scroll-aria.cy.ts b/cypress/e2e/perf-scroll-aria.cy.ts new file mode 100644 index 00000000..20d1288d --- /dev/null +++ b/cypress/e2e/perf-scroll-aria.cy.ts @@ -0,0 +1,162 @@ +/** cSpell:ignore vscomp posinset setsize */ + +/** + * Scrolling a large list must not do work proportional to the whole list. + * + * Measured against INP / long tasks and a 16.7 ms frame budget. + * + * Two costs sat on the scroll path. `calculateAriaMetadata()` walked every option and ran + * at the top of every `renderOptions()`, and `onOptionsScroll` was bound with no throttling, + * so a single drag produced one full O(n) re-render per scroll event (~9.5 ms at 100k + * unthrottled, ~44 ms at 4x CPU) and blocked the main thread for the whole gesture. + * + * The ARIA scan now runs only when the filtered set or its order changes, and scroll + * re-renders are coalesced to at most one per animation frame. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('Perf: scroll path does no O(n) work per event', { testIsolation: true }, () => { + const mountId = 'vs-perf-scroll'; + + type Vs = { + calculateAriaMetadata: () => void; + setVisibleOptions: () => void; + $optionsContainer: HTMLElement; + ariaSetSize: number; + scrollAnimationFrame: number | null; + destroy: () => void; + }; + + const instance = () => cy.get(`#${mountId}`).then(($e) => $e[0].virtualSelect as Vs); + + /** Count calls to an instance method without changing its behaviour. */ + const countCalls = (vs: Record, method: string, sink: { n: number }) => { + const original = vs[method].bind(vs); + + // eslint-disable-next-line no-param-reassign + vs[method] = (...args: unknown[]) => { + sink.n += 1; + return original(...args); + }; + }; + + const mount = (count: number, extra: Record = {}) => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => mountVs(win, mountId, { options: makeOptions(count), search: true, ...extra })); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + }; + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('does not rescan ARIA metadata while scrolling', () => { + mount(2000); + + const calls = { n: 0 }; + + instance().then((vs) => countCalls(vs as unknown as Record, 'calculateAriaMetadata', calls)); + + // Several distinct scroll positions, i.e. several real scroll events and re-renders. + [200, 600, 1200, 2000, 3000].forEach((top) => { + cy.get(`#${mountId}`).find('.vscomp-options-container').scrollTo(0, top); + }); + cy.wait(200); + + cy.wrap(null).should(() => { + expect(calls.n, 'calculateAriaMetadata calls during scrolling').to.equal(0); + }); + }); + + it('coalesces a burst of scroll events into at most one re-render per frame', () => { + mount(2000); + + const renders = { n: 0 }; + + instance().then((vs) => countCalls(vs as unknown as Record, 'setVisibleOptions', renders)); + + // Dispatch a burst synchronously: without coalescing this is one full re-render each. + cy.get(`#${mountId}`).then(($e) => { + const container = ($e[0].virtualSelect as Vs).$optionsContainer; + + for (let i = 1; i <= 20; i += 1) { + container.scrollTop = i * 40; + container.dispatchEvent(new Event('scroll')); + } + }); + + cy.wait(200); + + cy.wrap(null).should(() => { + expect(renders.n, '20 scroll events must not produce 20 re-renders').to.be.lessThan(20); + expect(renders.n, 'the final position must still be rendered').to.be.greaterThan(0); + }); + }); + + it('still reports correct aria-setsize and aria-posinset after scrolling', () => { + mount(2000); + + cy.get(`#${mountId}`).find('.vscomp-option[aria-setsize]').first().should('have.attr', 'aria-setsize', '2000'); + + cy.get(`#${mountId}`).find('.vscomp-options-container').scrollTo(0, 4000); + cy.wait(200); + + // setsize is a property of the filtered set, so scrolling must not change it... + cy.get(`#${mountId}`).find('.vscomp-option[aria-setsize]').first().should('have.attr', 'aria-setsize', '2000'); + // ...while posinset must have advanced with the window. + cy.get(`#${mountId}`) + .find('.vscomp-option[aria-posinset]') + .first() + .invoke('attr', 'aria-posinset') + .then((pos) => { + expect(Number(pos), 'first rendered option advanced after scrolling').to.be.greaterThan(1); + }); + }); + + it('recomputes metadata when the filtered set changes', () => { + mount(2000); + + cy.get(`#${mountId}`).find('.vscomp-search-input').focus().type('Option 15'); + + // "Option 15", "Option 150".."Option 159", "Option 1500".."Option 1599" -> a smaller set. + cy.get(`#${mountId}`) + .find('.vscomp-option[aria-setsize]') + .first() + .invoke('attr', 'aria-setsize') + .then((size) => { + expect(Number(size), 'setsize reflects the filtered set, not the full one').to.be.lessThan(2000); + expect(Number(size), 'setsize is still meaningful').to.be.greaterThan(0); + }); + + cy.get(`#${mountId}`).find('.vscomp-option[aria-posinset]').first().should('have.attr', 'aria-posinset', '1'); + }); + + it('recomputes metadata when the option set is replaced', () => { + mount(2000); + + cy.get(`#${mountId}`).then(($e) => $e[0].setOptions?.(makeOptions(7))); + + cy.get(`#${mountId}`).find('.vscomp-option[aria-setsize]').first().should('have.attr', 'aria-setsize', '7'); + }); + + it('cancels a queued scroll re-render on destroy', () => { + mount(2000); + + cy.get(`#${mountId}`).then(($e) => { + const vs = $e[0].virtualSelect as Vs; + + // Queue a frame, then tear down before it can run. + vs.$optionsContainer.scrollTop = 400; + vs.$optionsContainer.dispatchEvent(new Event('scroll')); + expect(vs.scrollAnimationFrame, 'a frame is queued').to.not.eq(null); + + vs.destroy(); + expect(vs.scrollAnimationFrame, 'the queued frame is cancelled on destroy').to.eq(null); + }); + + // A leaked frame would throw against detached DOM and fail the test via an uncaught error. + cy.wait(200); + }); +}); diff --git a/cypress/e2e/perf-text-measurer.cy.ts b/cypress/e2e/perf-text-measurer.cy.ts new file mode 100644 index 00000000..119d5cd1 --- /dev/null +++ b/cypress/e2e/perf-text-measurer.cy.ts @@ -0,0 +1,107 @@ +/** cSpell:ignore vscomp */ + +/** + * Measuring whether a tag's text overflows must not thrash layout. + * + * The check runs once per selected tag. It used to create a div, read getComputedStyle twice, + * append it to , read clientWidth and remove it again - and each DOM mutation + * invalidates layout for the read that follows, so rendering many tags produced a burst of + * forced synchronous layouts. + */ + +import { makeOptions, mountVs, unmountVs } from '../support/mount'; + +describe('Perf: one shared text measurer', { testIsolation: true }, () => { + const mountId = 'vs-measurer'; + + const mount = () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => + mountVs(win, mountId, { options: makeOptions(5), multiple: true, showValueAsTags: true }), + ); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + }; + + it('reuses a single off-screen node instead of one per tag', () => { + mount(); + + // Several tags, i.e. several willTextOverflow() calls per render. + ['o1', 'o2', 'o3', 'o4'].forEach((v) => { + cy.get(`#${mountId}`).find(`.vscomp-option[data-value="${v}"]`).click(); + }); + + cy.get('.vscomp-text-measurer').should('have.length', 1); + }); + + it('keeps the measurer out of the accessibility tree and out of flow', () => { + mount(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + + cy.get('.vscomp-text-measurer').should('have.attr', 'aria-hidden', 'true'); + cy.get('.vscomp-text-measurer').should('have.css', 'position', 'absolute'); + }); + + it('still detects overflow, so tag tooltips are unaffected', () => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => + mountVs(win, mountId, { + options: [ + { label: 'A label far too long to fit inside a narrow tag without being clipped', value: 'long' }, + { label: 'Ok', value: 'short' }, + ], + multiple: true, + showValueAsTags: true, + }), + ); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="long"]').click(); + // Overflowing text still gets a tooltip, i.e. the measurement still works. + cy.get(`#${mountId}`).find('.vscomp-value-tag').first().should('have.attr', 'data-tooltip'); + + cy.window().then((win) => unmountVs(win, mountId)); + }); + + /** + * The one case that needs to be the *only* instance on the page, so it mounts on a docs page + * that hosts no demos of its own. + * + * `get-started` keeps two instances alive, which is why this case used to be written as + * `if (remaining === 0) { ...assert... }` — a condition that is false on every run, so its only + * assertion was skipped every time: it reported green while verifying nothing, and + * `Utils.removeTextMeasurer()` was never exercised at all. + * + * Destroying the page's own instances to force the condition works in a plain browser but hangs + * the Cypress runner, so this takes the other route: `properties` renders real content, loads + * the same bundle and starts with zero instances (measured), which makes our mount genuinely the + * last one. Nothing outside this test is torn down, and the precondition is asserted rather than + * assumed — if that page ever gains a demo, this fails loudly instead of going quiet again. + */ + it('removes the measurer once the last instance is destroyed', () => { + cy.viewport(1280, 800); + cy.visit('properties'); + + cy.window().should((win) => { + // @ts-expect-error - bundle global + expect(win.VirtualSelect.activeInstances.size, 'this page hosts no instances of its own').to.equal(0); + }); + + cy.window().then((win) => + mountVs(win, mountId, { options: makeOptions(5), multiple: true, showValueAsTags: true }), + ); + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="o1"]').click(); + cy.get('.vscomp-text-measurer').should('exist'); + + cy.window().then((win) => unmountVs(win, mountId)); + + cy.window().should((win) => { + // @ts-expect-error - bundle global + expect(win.VirtualSelect.activeInstances.size, 'no instances left').to.equal(0); + }); + + cy.get('.vscomp-text-measurer').should('not.exist'); + }); +}); diff --git a/cypress/e2e/security-ampersand-storage.cy.ts b/cypress/e2e/security-ampersand-storage.cy.ts new file mode 100644 index 00000000..3b1fea66 --- /dev/null +++ b/cypress/e2e/security-ampersand-storage.cy.ts @@ -0,0 +1,305 @@ +/** cSpell:ignore vscomp pwned */ + +/** + * AI-22 stage 1 — escaping was applied to the text the library *stores*, so `&` corrupted the + * option's own identity. + * + * `secureText()` escapes by assigning to a text node and reading back `innerHTML`, which rewrites + * `&` to `&` (and `<`/`>`) by design — that rewriting is what makes `enableSecureText` work. + * The mistake was storing the result in `option.value` and deriving the search keys from it. + * + * The three fields have different constraints, and treating them alike is what caused this: + * + * - `value` reaches **no** `innerHTML` sink. It goes into the `data-value` attribute and is + * otherwise only compared and used as a map key. Escaping it protected nothing and made the + * option unaddressable: `setValue(['a&b'])` could not match a stored `a&b`. + * - `label` and `description` **are** inserted as HTML, so they must stay escaped. They still are. + * + * Stage 1 therefore stores `value` verbatim, escapes `&` as well as `"` at the `data-value` + * interpolation so the attribute still parses back to it, and derives `labelNormalized` / + * `descriptionNormalized` from the raw text so search matches what the consumer typed. + * + * Note the deliberate asymmetry with `DomUtils.getAttributesText()`, which escapes quotes only: + * its inputs are already-escaped label text, so escaping `&` there too would double it. + * + * Stage 2 — storing `label`/`description` raw and escaping at render — is breaking (a `labelRenderer` + * receiving a raw label turns the common `'' + d.label + ''` into an injection) and is held + * for 2.0.0. The cases at the end pin the escaping that stage 1 keeps. + */ + +import { mountVs, unmountVs } from '../support/mount'; + +describe('Security: option values are stored verbatim, not HTML-escaped', () => { + const mountId = 'vs-amp'; + + const ampOptions = [ + { label: 'Tom & Jerry', value: 'a&b' }, + { label: 'R&D', value: 'r&d' }, + { label: 'Angle ', value: 'x = {}) => + mountVs(win, mountId, { options: ampOptions, enableSecureText: true, ...extra }); + + const vs = () => cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect); + const openDropbox = () => cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + /** the dropbox is appended outside the host for the default `dropboxWrapper: 'self'` layout */ + const dropbox = () => cy.get(`#${mountId}`).then(($ele) => cy.wrap($ele[0].virtualSelect.$dropbox)); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + }); + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('stores option values exactly as supplied', () => { + cy.window().then((win) => mount(win)); + + vs().should((instance) => { + expect(instance.options.map((d: any) => d.value)).to.deep.equal(['a&b', 'r&d', 'x { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['a&b']); + }); + + vs().should((instance) => { + expect(instance.selectedValues).to.deep.equal(['a&b']); + }); + cy.get(`#${mountId}`).find('.vscomp-value').should('contain', 'Tom & Jerry'); + }); + + it('selects a "<" value through setValue', () => { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['x { + expect(instance.selectedValues).to.deep.equal(['x { + cy.window().then((win) => { + const $ele = win.document.getElementById(mountId) ?? mount(win); + const $host = win.document.getElementById(mountId) as HTMLElement; + void $ele; + + $host.setValue?.(['a&b']); + const persisted = ($host as unknown as { value: string }).value; + expect(persisted, 'the value read back must be the value supplied').to.equal('a&b'); + + $host.reset?.(false, true); + $host.setValue?.([persisted]); + }); + + vs().should((instance) => { + expect(instance.selectedValues).to.deep.equal(['a&b']); + }); + }); + + it('disables an "&" option through setDisabledOptions', () => { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setDisabledOptions?.(['a&b', 'plain']); + }); + + vs().should((instance) => { + const disabled = instance.options.filter((d: any) => d.isDisabled).map((d: any) => d.value); + + expect(disabled).to.deep.equal(['a&b', 'plain']); + }); + }); + + it('reports "&" values back from getDisabledOptions', () => { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setDisabledOptions?.(['r&d']); + }); + + cy.get(`#${mountId}`).should(($ele) => { + expect($ele[0].getDisabledOptions?.().map((d: any) => d.value)).to.deep.equal(['r&d']); + }); + }); + + it('finds a label containing "&" by searching for it', () => { + cy.window().then((win) => mount(win, { search: true })); + + openDropbox(); + cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect.setSearchValue('Tom & Jerry')); + + dropbox().find('.vscomp-option').should('have.length', 1); + dropbox().find('.vscomp-option').should('have.attr', 'data-value', 'a&b'); + }); + + it('finds a label containing "<" by searching for it', () => { + cy.window().then((win) => mount(win, { search: true })); + + openDropbox(); + cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect.setSearchValue('')); + + dropbox().find('.vscomp-option').should('have.length', 1); + dropbox().find('.vscomp-option').should('have.attr', 'data-value', 'x { + cy.window().then((win) => + mountVs(win, mountId, { + options: [ + { label: 'One', value: '1', description: 'Research & Development' }, + { label: 'Two', value: '2', description: 'Something else' }, + ], + hasOptionDescription: true, + enableSecureText: true, + search: true, + }), + ); + + openDropbox(); + cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect.setSearchValue('Research & Dev')); + + dropbox().find('.vscomp-option').should('have.length', 1); + dropbox().find('.vscomp-option').should('have.attr', 'data-value', '1'); + }); + + it('keeps data-value readable and un-breakable for an "&" value', () => { + cy.window().then((win) => mount(win)); + + openDropbox(); + dropbox().find('.vscomp-option').should(($options) => { + expect(Array.from($options).map((o) => (o as HTMLElement).dataset.value)).to.deep.equal([ + 'a&b', + 'r&d', + 'x { + const tricky = 'a&b" data-pwned="1" z="'; + + cy.window().then((win) => mountVs(win, mountId, { options: [{ label: 'Tricky', value: tricky }], enableSecureText: true })); + + openDropbox(); + dropbox().find('[data-pwned]').should('not.exist'); + dropbox().find('.vscomp-option').click(); + + vs().should((instance) => { + expect(instance.selectedValues).to.deep.equal([tricky]); + }); + }); + + it('receives the whole typed term, ampersand included', () => { + cy.window().then((win) => mount(win, { search: true, allowNewOption: true })); + + openDropbox(); + + /** + * realType(), not .type(): real key events over CDP rather than Cypress's simulated typing. + * + * With `.type()` this case stalled after the first character - the input's own value stayed + * `'S'` for the full retry window, so the term never reached the component at all. The cause + * is specific to Cypress's simulated typing: real per-character keyboard input against the + * built bundle carries the whole term through (verified in a browser - input value, + * searchValueOriginal, the derived option's value and its data-value were all correct), and + * `security-quote-escaping` types with `.type()` successfully. The distinguishing factor here + * is `allowNewOption`, which adds and updates a "current new" row on every keystroke. + * + * This is the same remedy AI-1e applied to key presses, for the same reason: drive the + * component with real events instead of simulated ones. realType() is a parent command that + * types into whatever holds focus, so the input is focused first - the pattern `pressKeys()` + * already uses. + */ + cy.get(`#${mountId}`).find('.vscomp-search-input').focus(); + cy.realType('Smith & Sons'); + + /** + * Kept separate from the storage assertion below. Folding "typing works" and "the value is + * stored verbatim" into one case made a dropped keystroke surface as a value mismatch, which + * reads like a storage bug and is not one. + */ + cy.get(`#${mountId}`).find('.vscomp-search-input').should('have.value', 'Smith & Sons'); + vs().should((instance) => { + expect(instance.searchValueOriginal, 'the component saw the whole term').to.equal('Smith & Sons'); + }); + }); + + it('stores a typed new option value verbatim', () => { + cy.window().then((win) => mount(win, { search: true, allowNewOption: true })); + + openDropbox(); + /** + * setSearchValue() is the entry point onSearch() calls for a keystroke, so this covers the + * same code path without depending on per-character typing into a list that re-renders on + * every input event - the coupling AI-1e removed elsewhere in this suite. + */ + cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect.setSearchValue('Smith & Sons')); + + // the option the component derives from the search text, before anything is clicked + vs().should((instance) => { + const created = instance.options.find((d: any) => d.isCurrentNew); + + expect(created, 'a new option is offered').to.not.equal(undefined); + expect(created.value, 'value stored verbatim').to.equal('Smith & Sons'); + }); + + // Scoped by data-value so the retry lands on the row that carries the full term, rather + // than whichever node the virtualiser happened to have rendered a moment earlier. + dropbox().find('.vscomp-option.current-new[data-value="Smith & Sons"]').click(); + + vs().should((instance) => { + expect(instance.selectedValues).to.deep.equal(['Smith & Sons']); + }); + }); + + // --- what stage 1 deliberately keeps --- + + it('still renders "&" in a label as a single ampersand', () => { + cy.window().then((win) => mount(win)); + + openDropbox(); + dropbox() + .find('.vscomp-option[data-value="a&b"] .vscomp-option-text') + .should(($text) => { + expect($text.text().trim()).to.equal('Tom & Jerry'); + }); + }); + + it('still escapes markup in a label, so option text cannot execute', () => { + cy.window().then((win) => { + // @ts-expect-error - test marker + win.__vsAmpXss = undefined; + mountVs(win, mountId, { + options: [{ label: '', value: 'p1' }], + enableSecureText: true, + }); + }); + + openDropbox(); + dropbox().find('img[src="x"]').should('not.exist'); + + cy.window().then((win) => { + // @ts-expect-error - test marker + expect(win.__vsAmpXss, 'payload must not execute').to.not.eq(true); + }); + }); + + it('leaves everything unchanged when escaping is off', () => { + cy.window().then((win) => mount(win, { enableSecureText: false })); + + vs().should((instance) => { + expect(instance.options.map((d: any) => d.value)).to.deep.equal(['a&b', 'r&d', 'x${this.selectAllText}`, where HTML works today and + * is presumably deliberate. Only the attribute occurrence changes; the last case pins that. + */ + +import { mountVs, unmountVs } from '../support/mount'; + +describe('Security: component label props cannot break out of their attributes', () => { + const mountId = 'vs-chrome'; + const payload = 'x" data-pwned="1" y="'; + + const mount = (win: Window, extra: Record = {}) => + mountVs(win, mountId, { + options: [{ label: 'Group', options: [{ label: 'Kid', value: 'k' }] }], + multiple: true, + search: true, + enableSecureText: true, + ...extra, + }); + + const openDropbox = () => cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + const dropbox = () => cy.get(`#${mountId}`).then(($ele) => cy.wrap($ele[0].virtualSelect.$dropbox)); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + }); + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('keeps the wrapper aria-label intact and injects nothing', () => { + cy.window().then((win) => mount(win, { ariaLabelText: payload })); + + cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('have.attr', 'aria-label', payload); + cy.get(`#${mountId}`).find('[data-pwned]').should('not.exist'); + }); + + it('keeps the clear button aria-label intact and injects nothing', () => { + cy.window().then((win) => mount(win, { ariaLabelClearButtonText: payload })); + + cy.get(`#${mountId}`).find('.vscomp-clear-button').should('have.attr', 'aria-label', payload); + cy.get(`#${mountId}`).find('[data-pwned]').should('not.exist'); + }); + + it('keeps aria-labelledby intact and injects nothing', () => { + cy.window().then((win) => mount(win, { ariaLabelledby: payload })); + + cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('have.attr', 'aria-labelledby', payload); + cy.get(`#${mountId}`).find('[data-pwned]').should('not.exist'); + }); + + it('keeps the search placeholder intact and injects nothing', () => { + cy.window().then((win) => mount(win, { searchPlaceholderText: payload })); + + openDropbox(); + dropbox().find('.vscomp-search-input').should('have.attr', 'placeholder', payload); + dropbox().find('[data-pwned]').should('not.exist'); + }); + + it('keeps the search clear aria-label intact and injects nothing', () => { + cy.window().then((win) => mount(win, { ariaLabelSearchClearButtonText: payload })); + + openDropbox(); + dropbox().find('.vscomp-search-clear').should('have.attr', 'aria-label', payload); + dropbox().find('[data-pwned]').should('not.exist'); + }); + + it('keeps the Select All aria-label intact and injects nothing', () => { + cy.window().then((win) => mount(win, { selectAllText: payload })); + + openDropbox(); + dropbox().find('.vscomp-toggle-all-button').should('have.attr', 'aria-label', payload); + // Previously the payload put a live attribute on this very button. + dropbox().find('.vscomp-toggle-all-button').should('not.have.attr', 'data-pwned'); + dropbox().find('[data-pwned]').should('not.exist'); + }); + + it('injects nothing anywhere when every affected prop carries the payload', () => { + cy.window().then((win) => + mount(win, { + ariaLabelText: payload, + ariaLabelledby: payload, + ariaLabelClearButtonText: payload, + ariaLabelSearchClearButtonText: payload, + selectAllText: payload, + searchPlaceholderText: payload, + }), + ); + + openDropbox(); + cy.get(`#${mountId}`).find('[data-pwned]').should('not.exist'); + dropbox().find('[data-pwned]').should('not.exist'); + }); + + it('strips markup from an accessible name, as AI-14 does for option labels', () => { + cy.window().then((win) => mount(win, { selectAllText: 'Pick all' })); + + openDropbox(); + // A screen reader should hear "Pick all", not the literal tag soup it heard before. + dropbox().find('.vscomp-toggle-all-button').should('have.attr', 'aria-label', 'Pick all'); + }); + + it('still renders HTML in the visible Select All label', () => { + cy.window().then((win) => mount(win, { selectAllText: 'Pick all' })); + + openDropbox(); + // The other sink for the same prop. Escaping this one would be a visible regression. + dropbox().find('.vscomp-toggle-all-label b').should('contain', 'all'); + }); + + it('leaves plain values untouched', () => { + cy.window().then((win) => + mount(win, { + ariaLabelText: 'Countries', + selectAllText: 'Select all', + searchPlaceholderText: 'Search...', + }), + ); + + cy.get(`#${mountId}`).find('.vscomp-ele-wrapper').should('have.attr', 'aria-label', 'Countries'); + + openDropbox(); + dropbox().find('.vscomp-toggle-all-button').should('have.attr', 'aria-label', 'Select all'); + dropbox().find('.vscomp-toggle-all-label').should('contain', 'Select all'); + dropbox().find('.vscomp-search-input').should('have.attr', 'placeholder', 'Search...'); + }); +}); diff --git a/cypress/e2e/security-global-defaults.cy.ts b/cypress/e2e/security-global-defaults.cy.ts new file mode 100644 index 00000000..b5ea93b1 --- /dev/null +++ b/cypress/e2e/security-global-defaults.cy.ts @@ -0,0 +1,254 @@ +/** cSpell:ignore vscomp */ + +/** + * A host application must be able to turn option-text escaping on for every dropdown at once. + * + * OWASP A03:2021 (Injection) / DOM XSS. + * + * Option label/value/description are interpolated into innerHTML, and `secureText()` is a + * no-op unless `enableSecureText` is on — which it is not by default. A host application + * previously had no way to turn escaping on for every dropdown at once; it had to remember + * the flag at each of possibly hundreds of call sites. + * + * VirtualSelect.setGlobalDefaults() closes that gap without changing the per-instance + * default, so existing consumers who deliberately render HTML labels are unaffected. + */ + +import { mountVs, unmountVs } from '../support/mount'; + +describe('Security: global defaults for enableSecureText', () => { + const mountId = 'vs-sec-global'; + + /** + * Every test detonates into its own marker, because the payload here is genuinely live. + * + * Test isolation is off and `cy.visit()` only changes the hash, so all of these tests share a + * single window that is never reloaded — this file already depends on that (see the + * `secureTextWarningShown` reset in the last test). The insecure-by-default test creates a real + * ``, and an image error event is asynchronous: measured against the built bundle it + * fires 7 ms after the "the element exists" assertion has already passed, and *again* at 313 ms, + * because the dropbox re-renders when its 300 ms open animation finishes and so produces a + * second live image. + * + * With one page-wide marker, that second detonation landed two tests later — after the + * `beforeEach` that had reset it — and failed whichever test was running at the time while the + * component under test was behaving perfectly. A marker per test removes the cross-talk instead + * of papering over it with a wait. + */ + let markerSeq = 0; + const newMarker = () => `__vsGlobalXss${(markerSeq += 1)}`; + const payloadFor = (marker: string) => ``; + const markerValue = (win: Window, marker: string) => (win as unknown as Record)[marker]; + + const resetGlobals = (win: Window) => { + /** + * `setGlobalDefaults()` merges, so `{}` cannot clear a key an earlier test set, and nothing + * reloads the page to do it for us. resetGlobalDefaults() is the explicit clearing API. + */ + // @ts-expect-error - VirtualSelect is attached to window by the bundle + win.VirtualSelect.resetGlobalDefaults(); + }; + + const mountWithPayload = (win: Window, marker: string, extra: Record = {}) => + mountVs(win, mountId, { options: [{ label: payloadFor(marker), value: 'p1' }], ...extra }); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then(resetGlobals); + }); + + afterEach(() => { + cy.window().then((win) => { + unmountVs(win, mountId); + resetGlobals(win); + }); + }); + + it('exposes setGlobalDefaults and getGlobalDefaults', () => { + cy.window().then((win) => { + // @ts-expect-error - bundle global + expect(win.VirtualSelect.setGlobalDefaults).to.be.a('function'); + // @ts-expect-error - bundle global + expect(win.VirtualSelect.getGlobalDefaults).to.be.a('function'); + }); + }); + + it('still renders option text as raw HTML by default, so behaviour is unchanged', () => { + const marker = newMarker(); + + cy.window().then((win) => mountWithPayload(win, marker)); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + // The documented, insecure-by-default behaviour: a real element is created. + cy.get(`#${mountId}`).find('.vscomp-option[data-value="p1"] img[src="x"]').should('exist'); + + /** + * And it is not inert. Asserting the detonation rather than only the element proves the + * default really does execute attacker-controlled markup, which is the whole reason + * setGlobalDefaults() exists; `should` retries, and the first error event lands within ~10 ms. + */ + cy.window().should((win) => { + expect(markerValue(win, marker), 'the insecure default really does execute the payload').to.eq(true); + }); + }); + + it('escapes option text for instances created after a global default is set', () => { + const marker = newMarker(); + + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + mountWithPayload(win, marker); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="p1"]').should('exist'); + cy.get(`#${mountId}`).find('img[src="x"]').should('not.exist'); + // The payload is visible as text rather than parsed as markup. + cy.get(`#${mountId}`).find('.vscomp-option[data-value="p1"] .vscomp-option-text').should('contain', 'img'); + + cy.window().then((win) => { + expect(markerValue(win, marker), 'payload must not execute').to.not.eq(true); + }); + }); + + it('does not let an undefined prop defeat the global default', () => { + /** + * `Object.assign` copies own enumerable keys *including* those whose value is `undefined`, so a + * prop forwarded from an unset variable overwrote the global instead of falling back to it — the + * escaping policy was silently off while the host believed it had enabled it page-wide. + * + * This is the exact shape a wrapper uses: `enableSecureText: this.SanitizeDropdownValues`, where + * the wrapper property may be undefined. It also contradicts setDefaultProps()'s own `resolve()` + * helper, which already treats `undefined` as "not supplied". + */ + const marker = newMarker(); + + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + mountWithPayload(win, marker, { enableSecureText: undefined }); + }); + + cy.get(`#${mountId}`).should(($ele) => { + expect($ele[0].virtualSelect.enableSecureText, 'the global must still win').to.equal(true); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + // Assert the option rendered *first*: `img` never existing is also true of a list that never + // rendered at all, so on its own that check can pass for the wrong reason. + cy.get(`#${mountId}`).find('.vscomp-option[data-value="p1"] .vscomp-option-text').should('contain', 'img'); + cy.get(`#${mountId}`).find('img[src="x"]').should('not.exist'); + + cy.window().then((win) => { + expect(markerValue(win, marker), 'payload must not execute').to.not.eq(true); + }); + }); + + it('lets an explicit per-instance option override the global default', () => { + const marker = newMarker(); + + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + // Opting back out for a trusted, HTML-rendering list. + mountWithPayload(win, marker, { enableSecureText: false }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + + cy.get(`#${mountId}`).find('.vscomp-option[data-value="p1"] img[src="x"]').should('exist'); + }); + + it('merges successive calls rather than replacing the whole set', () => { + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ placeholder: 'Pick one' }); + // @ts-expect-error - bundle global + const globals = win.VirtualSelect.getGlobalDefaults(); + + expect(globals).to.deep.include({ enableSecureText: true, placeholder: 'Pick one' }); + }); + }); + + it('applies to any prop, not just enableSecureText', () => { + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ placeholder: 'Choose a value' }); + mountVs(win, mountId, { options: [{ label: 'One', value: '1' }] }); + }); + + // `contain`, not `have.text`: the value template renders with surrounding whitespace. + cy.get(`#${mountId}`).find('.vscomp-value').should('contain', 'Choose a value'); + }); + + it('returns a copy from getGlobalDefaults, so callers cannot mutate internal state', () => { + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + // @ts-expect-error - bundle global + const globals = win.VirtualSelect.getGlobalDefaults(); + globals.enableSecureText = false; + + // @ts-expect-error - bundle global + expect(win.VirtualSelect.getGlobalDefaults().enableSecureText).to.eq(true); + }); + }); + + it('ignores a non-object argument instead of wiping the configured policy', () => { + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + + // A host accidentally forwarding an unset config variable must not silently turn + // page-wide escaping off; clearing is an explicit act (resetGlobalDefaults). + // @ts-expect-error - bundle global, deliberately wrong argument + win.VirtualSelect.setGlobalDefaults(undefined); + // @ts-expect-error - bundle global, deliberately wrong argument + win.VirtualSelect.setGlobalDefaults(null); + // @ts-expect-error - bundle global, deliberately wrong argument + win.VirtualSelect.setGlobalDefaults('enableSecureText'); + + // @ts-expect-error - bundle global + expect(win.VirtualSelect.getGlobalDefaults().enableSecureText).to.eq(true); + }); + }); + + it('clears every configured default only through the explicit resetGlobalDefaults()', () => { + cy.window().then((win) => { + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true, placeholder: 'Pick one' }); + + // @ts-expect-error - bundle global + win.VirtualSelect.resetGlobalDefaults(); + + // @ts-expect-error - bundle global + expect(win.VirtualSelect.getGlobalDefaults()).to.deep.equal({}); + }); + }); + + it('suppresses the insecure-by-default console warning when the global default is on', () => { + const marker = newMarker(); + + cy.window().then((win) => { + // @ts-expect-error - internal flag reset so the once-per-page guard does not hide the result + win.VirtualSelect.secureTextWarningShown = false; + cy.spy(win.console, 'warn').as('consoleWarn'); + + // @ts-expect-error - bundle global + win.VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + mountWithPayload(win, marker); + }); + + cy.get('@consoleWarn').should((spy: any) => { + const messages = spy.getCalls().map((c: any) => String(c.args[0])); + expect(messages.filter((m: string) => m.includes('enableSecureText'))).to.have.length(0); + }); + }); +}); diff --git a/cypress/e2e/security-hidden-input-name.cy.ts b/cypress/e2e/security-hidden-input-name.cy.ts new file mode 100644 index 00000000..6efa6c48 --- /dev/null +++ b/cypress/e2e/security-hidden-input-name.cy.ts @@ -0,0 +1,179 @@ +/** cSpell:ignore vscomp pwned */ + +/** + * SEC-02 — the `name` prop is interpolated raw into the hidden input's `name` attribute. + * + * OWASP A03:2021 (Injection) / DOM XSS, plus a functional submission bug. + * + * `renderWrapper()` built the field as ``. Because + * `name` reaches that template as a raw string, a double quote in it terminates the attribute + * early: the rest of the payload is parsed as markup, and the field keeps only the *prefix* up + * to that quote. `items["a"]` submitted as `items[` — a wrong-but-plausible field name rather + * than an obvious absence, which is why it went unnoticed. So the same character both injects + * and silently corrupts submission, and it does that to an entirely legitimate field name. + * + * `enableSecureText: true` stopped the injection but not the breakage: the escaped `"` + * became part of the submitted field name. + * + * There is a third defect at the same site. `name` is interpolated *before* the field's + * `class="vscomp-hidden-input"`, so a payload that closes the tag swallows the class too. + * `querySelector('.vscomp-hidden-input')` then returns null, and the first `setValue()` throws + * `Cannot set properties of null (setting 'value')` inside the constructor — which the library + * catches as "Couldn't initiate Virtual Select" and leaves the host element empty. A quote in + * `name` plus any initial value is therefore a total loss of the control, not just of the + * submitted field. + * + * The fix keeps the field and assigns the name as a DOM *property* after render. A property + * assignment involves no HTML parsing, so there is nothing to break out of and nothing to + * escape — the name submits verbatim whether escaping is on or off. + * + * These cases assert the submission contract through `FormData`, i.e. what the server actually + * receives, rather than through the attribute alone. + */ + +import { unmountVs } from '../support/mount'; + +describe('Security: hidden input name is not an HTML sink', () => { + const mountId = 'vs-sec-name'; + const formId = 'vs-sec-name-form'; + const payload = 'x" data-pwned="1">'; + const quotedName = 'items["a"]'; + + /** + * Mount inside a real `
`, because the whole point of the field is submission. + * `setEleProps()` resolves `$ele.form` via `closest('form')` at init time, so the host has to + * be in the form before `init()` runs. + */ + const mountInForm = (win: Window, options: Record): void => { + unmountVs(win, mountId); + win.document.getElementById(formId)?.remove(); + + const $form = win.document.createElement('form'); + $form.id = formId; + + const $ele = win.document.createElement('div'); + $ele.id = mountId; + $form.appendChild($ele); + win.document.body.appendChild($form); + + // @ts-expect-error - VirtualSelect is attached to window by the bundle + win.VirtualSelect.init({ + ele: $ele, + options: [ + { label: 'Portugal', value: 'pt' }, + { label: 'Spain', value: 'es' }, + ], + ...options, + }); + }; + + /** + * The entries the form would actually submit. + * + * Built with the application window's own FormData so the read happens in the same realm as + * the form - `FormData` lives on the global scope rather than on the `Window` interface, hence + * the cast. + */ + const submitted = (win: Window): FormData => { + const $form = win.document.getElementById(formId) as HTMLFormElement; + const WinFormData = (win as unknown as { FormData: typeof FormData }).FormData; + + return new WinFormData($form); + }; + + const submittedNames = (win: Window): string[] => Array.from(submitted(win).keys()); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + }); + + afterEach(() => { + cy.window().then((win) => { + unmountVs(win, mountId); + win.document.getElementById(formId)?.remove(); + }); + }); + + it('submits a plain name, unchanged', () => { + cy.window().then((win) => { + mountInForm(win, { name: 'country', selectedValue: 'pt' }); + + expect(submittedNames(win)).to.deep.equal(['country']); + expect(submitted(win).get('country')).to.equal('pt'); + }); + }); + + it('does not create DOM from a name containing a double quote', () => { + cy.window().then((win) => { + // enableSecureText is deliberately left at its default (off): the fix must not depend on it. + mountInForm(win, { name: payload }); + + expect( + win.document.querySelectorAll(`#${mountId} [data-pwned]`).length, + 'no element may be created from the name', + ).to.equal(0); + expect(win.document.querySelectorAll(`#${mountId} img`).length, 'no injected img').to.equal(0); + }); + }); + + it('still submits under a name containing a double quote, verbatim', () => { + cy.window().then((win) => { + mountInForm(win, { name: payload, selectedValue: 'pt' }); + + expect(submittedNames(win), 'the field must not be dropped from the form').to.deep.equal([payload]); + }); + }); + + it('submits a legitimately quoted field name verbatim', () => { + cy.window().then((win) => { + mountInForm(win, { name: quotedName, selectedValue: 'es' }); + + expect(submittedNames(win)).to.deep.equal([quotedName]); + expect(submitted(win).get(quotedName)).to.equal('es'); + }); + }); + + it('does not entity-escape the field name when enableSecureText is on', () => { + cy.window().then((win) => { + mountInForm(win, { name: quotedName, selectedValue: 'pt', enableSecureText: true }); + + // Escaping protects HTML sinks; the name is no longer one, so it must not be rewritten. + expect(submittedNames(win)).to.deep.equal([quotedName]); + }); + }); + + it('still builds the control when the name contains a double quote and a value is set', () => { + cy.window().then((win) => { + cy.spy(win.console, 'error').as('consoleError'); + + // The combination that used to abort the constructor: the payload swallowed the field's + // class attribute, so setValue() dereferenced a null $hiddenInput. + mountInForm(win, { name: payload, selectedValue: 'pt' }); + + expect(win.document.querySelectorAll(`#${mountId} .vscomp-wrapper`).length, 'wrapper rendered').to.equal(1); + expect(win.document.querySelectorAll(`#${mountId} .vscomp-hidden-input`).length, 'field present').to.equal(1); + }); + + cy.get(`#${mountId}`).should(($ele) => { + expect($ele[0].virtualSelect, 'instance survived init').to.not.equal(undefined); + expect($ele[0].virtualSelect.selectedValues, 'value applied').to.deep.equal(['pt']); + }); + + cy.get('@consoleError').should((spy: any) => { + const messages = spy.getCalls().map((c: any) => String(c.args[0])); + expect(messages.filter((m: string) => m.includes('setting \'value\''))).to.have.length(0); + }); + }); + + it('keeps the name attribute on the hidden input itself', () => { + cy.window().then((win) => { + mountInForm(win, { name: quotedName }); + + const $hidden = win.document.querySelector(`#${mountId} .vscomp-hidden-input`) as HTMLInputElement; + + expect($hidden, 'the hidden input must still exist').to.not.equal(null); + expect($hidden.getAttribute('name')).to.equal(quotedName); + }); + }); +}); diff --git a/cypress/e2e/security-proto-value.cy.ts b/cypress/e2e/security-proto-value.cy.ts new file mode 100644 index 00000000..6427862b --- /dev/null +++ b/cypress/e2e/security-proto-value.cy.ts @@ -0,0 +1,197 @@ +/** cSpell:ignore vscomp */ + +/** + * SEC-03 — option values are used as keys of plain `{}` objects, so `__proto__` is unusable. + * + * This is **not** prototype pollution. `mapping['__proto__'] = true` on a plain object invokes + * the inherited `__proto__` setter, which ignores a non-object value: nothing is written, and + * `Object.prototype` is untouched. The first case below pins that, so the assessment stays + * honest if the implementation changes. + * + * What it *is* is a state-rehydration bug, and an asymmetric one. Reading + * `mapping['__proto__']` returns the inherited `Object.prototype`, which is truthy but never + * `=== true` — and every one of these lookups compares against `true`. So the option is + * reachable by click (that path never consults a mapping) but not by API: + * + * - `setValue(['__proto__'])` silently selects nothing; + * - reading `element.value` and feeding it straight back loses the selection; + * - `setDisabledOptions` / `setEnabledOptions` silently skip it; + * - with `allowNewOption`, it is additionally mistaken for an unknown value and duplicated + * as a new option. + * + * `Object.create(null)` for the value-keyed lookups removes the inherited members, so an + * arbitrary string key behaves like any other. `constructor` and `toString` are covered too: + * they shadow correctly on a plain object and already worked, so they are the control that + * proves the fix did not change ordinary behaviour. + */ + +import { mountVs, unmountVs } from '../support/mount'; + +describe('Security: option values that collide with Object.prototype members', () => { + const mountId = 'vs-sec-proto'; + + const protoOptions = [ + { label: 'Proto', value: '__proto__' }, + { label: 'Constructor', value: 'constructor' }, + { label: 'ToString', value: 'toString' }, + { label: 'Plain A', value: 'a' }, + { label: 'Plain B', value: 'b' }, + ]; + + const mount = (win: Window, extra: Record = {}) => + mountVs(win, mountId, { options: protoOptions, ...extra }); + + /** The instance, for driving the public API the way a consumer does. */ + const vs = () => cy.get(`#${mountId}`).then(($ele) => $ele[0].virtualSelect); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + }); + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('does not pollute Object.prototype', () => { + cy.window().then((win) => { + /** + * The probe must be built in the *application's* realm. + * + * Spec code runs in the Cypress runner frame; `cy.window()` returns the application + * iframe's window. Those are separate realms with separate intrinsics, so an object + * literal written here has the runner frame's `Object.prototype` on its chain and can + * never `equal` `win.Object.prototype` — that comparison fails whether or not anything + * was polluted, which is exactly how this case first went wrong. + * + * So: snapshot the application realm's own `Object.prototype` members, run the + * operations, and compare. A pollution would show up as a new member, and a prototype + * swap as a fresh object no longer inheriting from it. + */ + const appObject = (win as unknown as { Object: ObjectConstructor }).Object; + const membersBefore = Object.getOwnPropertyNames(appObject.prototype).sort().join(','); + + mount(win); + const $ele = win.document.getElementById(mountId) as HTMLElement; + + $ele.setValue?.(['__proto__']); + $ele.setDisabledOptions?.(['__proto__']); + $ele.setEnabledOptions?.(['__proto__']); + + expect( + Object.getOwnPropertyNames(appObject.prototype).sort().join(','), + 'Object.prototype must not gain a member', + ).to.equal(membersBefore); + + const probe = new appObject(); + expect(Object.getPrototypeOf(probe), 'a fresh object still inherits from it').to.equal(appObject.prototype); + expect(typeof appObject.prototype, 'it is still an object, not a replaced value').to.equal('object'); + }); + }); + + it('selects a "__proto__" value through setValue', () => { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['__proto__']); + }); + + vs().should((instance) => { + expect(instance.selectedValues, 'selection').to.deep.equal(['__proto__']); + }); + cy.get(`#${mountId}`).find('.vscomp-value').should('contain', 'Proto'); + }); + + it('round-trips a "__proto__" selection through element.value', () => { + cy.window().then((win) => { + mount(win); + const $ele = win.document.getElementById(mountId) as HTMLElement; + + // Read the live value the way an app persisting state would, then restore it. + $ele.setValue?.(['__proto__']); + const persisted = ($ele as unknown as { value: string }).value; + $ele.reset?.(false, true); + $ele.setValue?.([persisted]); + }); + + vs().should((instance) => { + expect(instance.selectedValues, 'restored selection').to.deep.equal(['__proto__']); + }); + }); + + it('selects "__proto__" alongside ordinary values in multiple mode', () => { + cy.window().then((win) => { + mount(win, { multiple: true }); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['__proto__', 'a', 'b']); + }); + + vs().should((instance) => { + expect(instance.selectedValues).to.deep.equal(['__proto__', 'a', 'b']); + }); + }); + + it('disables a "__proto__" option through setDisabledOptions', () => { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setDisabledOptions?.(['__proto__', 'a']); + }); + + vs().should((instance) => { + const disabled = instance.options.filter((d: any) => d.isDisabled).map((d: any) => d.value); + + expect(disabled).to.deep.equal(['__proto__', 'a']); + }); + }); + + it('keeps a "__proto__" option enabled through setEnabledOptions', () => { + cy.window().then((win) => { + mount(win); + (win.document.getElementById(mountId) as HTMLElement).setEnabledOptions?.(['__proto__']); + }); + + vs().should((instance) => { + const enabled = instance.options.filter((d: any) => !d.isDisabled).map((d: any) => d.value); + + expect(enabled, 'only the named value stays enabled').to.deep.equal(['__proto__']); + }); + }); + + it('does not duplicate a "__proto__" value as a new option', () => { + cy.window().then((win) => { + mount(win, { allowNewOption: true }); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['__proto__']); + }); + + vs().should((instance) => { + const matches = instance.options.filter((d: any) => d.value === '__proto__'); + + expect(matches, 'the existing option must be reused, not re-added').to.have.length(1); + expect(instance.selectedValues).to.deep.equal(['__proto__']); + }); + }); + + it('keeps "__proto__" in place when selection order is preserved', () => { + cy.window().then((win) => { + mount(win, { multiple: true }); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['b', '__proto__', 'a']); + }); + + vs().should((instance) => { + const ordered = instance + .getSelectedOptions({ fullDetails: true, keepSelectionOrder: true }) + .map((d: any) => d.value); + + expect(ordered).to.deep.equal(['b', '__proto__', 'a']); + }); + }); + + it('still handles other Object.prototype member names (control)', () => { + cy.window().then((win) => { + mount(win, { multiple: true }); + (win.document.getElementById(mountId) as HTMLElement).setValue?.(['constructor', 'toString']); + }); + + vs().should((instance) => { + expect(instance.selectedValues).to.deep.equal(['constructor', 'toString']); + }); + }); +}); diff --git a/cypress/e2e/security-quote-escaping.cy.ts b/cypress/e2e/security-quote-escaping.cy.ts new file mode 100644 index 00000000..ecfd2517 --- /dev/null +++ b/cypress/e2e/security-quote-escaping.cy.ts @@ -0,0 +1,201 @@ +/** cSpell:ignore vscomp pwned */ + +/** + * SEC-04 — quotes were escaped in the wrong place: in the stored text, not at the attribute. + * + * `secureText()` ran `replaceDoubleQuotesWithHTML()` over label/value/description *before* + * handing the string to a text node, and the text node's `innerHTML` then escaped the `&` it + * had just introduced. So with `enableSecureText: true` a label of `The "City" of Light` was + * stored as `The &quot;City&quot; of Light`, rendered to the user as the visible + * mojibake `The "City" of Light`, and - because `labelNormalized` derives from the + * stored text - became unsearchable: neither `"City"` nor `"City"` matched anything. + * + * Escaping the source text also failed at what it was for. Quotes only matter inside an + * attribute, and two attribute sinks take option text: + * + * 1. `data-value="${d.value}"` in `renderOptions()`, always; + * 2. `data-tooltip="${label}"` via `getTooltipAttrText()` in the value-tag path, whose own + * escaping was conditional on `containsHTML(label)` - so a payload with no tag at all + * went in raw and put a live attribute on the tag element. + * + * Sink 2 was a real breakout whenever escaping was off, and sink 1 whether it was on or off. + * Pre-escaping the stored text only ever masked sink 2, and only in the escaping-on case. + * + * The fix moves the escaping to the boundary: `DomUtils.getAttributesText()` escapes every + * value it writes, `data-value` is escaped at its interpolation, and `secureText()` stops + * rewriting quotes. Attribute values round-trip through the parser, so `dataset.value` still + * reads back the exact option value. + */ + +import { mountVs, unmountVs } from '../support/mount'; + +describe('Security: quotes are escaped at the attribute, not in the stored text', () => { + const mountId = 'vs-sec-quotes'; + const quotedLabel = 'The "City" of Light'; + const attrPayload = 'x" data-pwned="1" z="'; + const scriptPayload = ''; + /** long enough that the tag needs a tooltip, which is what puts the label in an attribute */ + const longAttrPayload = `${attrPayload}${' padding to force overflow in a narrow field '.repeat(3)}`; + + const mount = (win: Window, options: Record) => mountVs(win, mountId, options); + + beforeEach(() => { + cy.viewport(1280, 800); + cy.visit('get-started'); + cy.window().then((win) => { + // @ts-expect-error - test marker + win.__vsQuoteXss = undefined; + }); + }); + + afterEach(() => { + cy.window().then((win) => unmountVs(win, mountId)); + }); + + it('stores a quoted label verbatim when escaping is on', () => { + cy.window().then((win) => { + mount(win, { options: [{ label: quotedLabel, value: 'paris' }], enableSecureText: true }); + }); + + cy.get(`#${mountId}`).should(($ele) => { + expect($ele[0].virtualSelect.options[0].label, 'stored label').to.equal(quotedLabel); + }); + }); + + it('renders a quoted label as real quotes, not as "', () => { + cy.window().then((win) => { + mount(win, { options: [{ label: quotedLabel, value: 'paris' }], enableSecureText: true }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="paris"] .vscomp-option-text').should(($text) => { + expect($text.text().trim()).to.equal(quotedLabel); + expect($text.text()).to.not.contain('"'); + }); + }); + + it('finds a quoted phrase by searching for it', () => { + cy.window().then((win) => { + mount(win, { + options: [ + { label: quotedLabel, value: 'paris' }, + { label: 'Lisbon', value: 'lisbon' }, + ], + search: true, + enableSecureText: true, + }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-search-input').type('"City"'); + cy.get(`#${mountId}`).find('.vscomp-option').should('have.length', 1); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="paris"]').should('exist'); + }); + + it('keeps a quoted description readable when escaping is on', () => { + cy.window().then((win) => { + mount(win, { + options: [{ label: 'Paris', value: 'paris', description: 'He said "bonjour"' }], + hasOptionDescription: true, + enableSecureText: true, + }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option-description').should(($d) => { + expect($d.text().trim()).to.equal('He said "bonjour"'); + expect($d.text()).to.not.contain('"'); + }); + }); + + // data-value is the always-active attribute sink. + [false, true].forEach((enableSecureText) => { + it(`does not let a quoted value break out of data-value (enableSecureText: ${enableSecureText})`, () => { + cy.window().then((win) => { + mount(win, { options: [{ label: 'Payload', value: attrPayload }], enableSecureText }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option').should('have.length', 1); + cy.get(`#${mountId}`).find('[data-pwned]').should('not.exist'); + + // The parsed attribute must still equal the value, or selection by click breaks. + cy.get(`#${mountId}`).find('.vscomp-option').should(($option) => { + expect($option[0].dataset.value, 'data-value round-trip').to.equal(attrPayload); + }); + }); + }); + + it('still selects an option whose value contains a quote', () => { + cy.window().then((win) => { + mount(win, { options: [{ label: 'Payload', value: attrPayload }] }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option').click(); + + cy.get(`#${mountId}`).should(($ele) => { + expect($ele[0].virtualSelect.selectedValues).to.deep.equal([attrPayload]); + }); + }); + + // The value-tag tooltip is the second sink, and was unguarded whenever escaping was off. + [false, true].forEach((enableSecureText) => { + it(`does not let a quoted label break out of data-tooltip (enableSecureText: ${enableSecureText})`, () => { + cy.window().then((win) => { + const $ele = mount(win, { + options: [{ label: longAttrPayload, value: 'v1' }], + multiple: true, + showValueAsTags: true, + selectedValue: ['v1'], + enableSecureText, + }); + $ele.style.width = '150px'; + }); + + cy.get(`#${mountId}`).find('[data-pwned]').should('not.exist'); + cy.get(`#${mountId}`).find('.vscomp-value-tag').should(($tag) => { + expect($tag.attr('data-tooltip'), 'tooltip round-trip').to.equal(longAttrPayload); + }); + }); + }); + + it('still escapes markup in option text when escaping is on', () => { + cy.window().then((win) => { + mount(win, { options: [{ label: scriptPayload, value: 'p1' }], enableSecureText: true }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('img[src="x"]').should('not.exist'); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="p1"] .vscomp-option-text').should('contain', 'img'); + + cy.window().then((win) => { + // @ts-expect-error - test marker + expect(win.__vsQuoteXss, 'payload must not execute').to.not.eq(true); + }); + }); + + it('leaves non-string tooltip attribute values intact', () => { + // getAttributesText() now stringifies before escaping. `Utils.getString(false)` returns '', + // so using it there would have silently emptied the boolean tooltip attributes. + cy.window().then((win) => { + mount(win, { options: [{ label: 'Plain', value: 'p' }] }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option .vscomp-option-text').should(($text) => { + expect($text.attr('data-tooltip-ellipsis-only')).to.equal('true'); + expect($text.attr('data-tooltip-allow-html')).to.equal('true'); + expect($text.attr('data-tooltip-enter-delay')).to.equal('200'); + }); + }); + + it('still renders HTML labels as markup when escaping is off (unchanged default)', () => { + cy.window().then((win) => { + mount(win, { options: [{ label: 'Bold', value: 'b1' }] }); + }); + + cy.get(`#${mountId}`).find('.vscomp-toggle-button').click(); + cy.get(`#${mountId}`).find('.vscomp-option[data-value="b1"] b').should('contain', 'Bold'); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 45b7ef6c..ccce27e3 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -4,13 +4,18 @@ import 'cypress-real-events'; const dropboxCloseDuration = 200; const optionsScrollDuration = 300; -type SpecialKey = 'Tab' | 'Enter' | 'Escape' | 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End'; +/** + * The one source of truth for pressKeys(): the type, the guard and the error message are all + * derived from it, so they cannot drift out of step with each other. + */ +const SPECIAL_KEYS = [ + 'Tab', 'Enter', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', +] as const; + +type SpecialKey = (typeof SPECIAL_KEYS)[number]; // Type guard function -const isValidKey = (key: string): key is SpecialKey => { - const specialKeys = ['Tab', 'Enter', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']; - return specialKeys.includes(key); -}; +const isValidKey = (key: string): key is SpecialKey => (SPECIAL_KEYS as readonly string[]).includes(key); Cypress.Commands.add('goToSection', (title) => { cy.get('a').contains(title).click({force: true}); @@ -30,6 +35,58 @@ Cypress.Commands.add('open', (id) => { cy.getVs(id).wait(dropboxCloseDuration).click(); }); +/** + * Open a dropdown from a known state, without depending on how the previous test left it. + * + * `cy.open()` is a click, i.e. a *toggle*: on an already-open dropdown it closes instead, and + * its fixed `wait(dropboxCloseDuration)` races the hide transition, which is what made the + * keyboard cases in this suite order-coupled (`testIsolation: false` keeps every instance + * alive across tests). This command asserts its way to the state it needs instead of waiting a + * fixed time for it: + * + * 1. clear the value, the filter and the scroll position, and ask the dropbox to close; + * 2. wait for it to actually be closed (`closed` class), so the click below always opens; + * 3. click, and wait for it to actually be open; + * 4. assert no option carries the highlight, so a following ArrowDown always lands on the + * first option and press counts stop depending on test order. + * + * All four inputs a press count can depend on are pinned, not three. `scrollTop` is one of + * them because `focusOption()` resolves "the first option" through + * `getFirstVisibleOptionIndex()`, i.e. `scrollTop / optionHeight` - and opening does not reset + * it (`setScrollTop()` returns early with no selection, and `scrollToTop()` only runs under + * `showSelectedOptionsFirst`). The filter is cleared explicitly rather than relying on + * `closeDropbox()` doing it, because `closeDropbox()` returns early when already closed and + * `reset()` never touches the search value. + */ +Cypress.Commands.add('openFresh', (id) => { + cy.getVs(id).then(($ele) => { + const vs = $ele[0].virtualSelect; + vs.reset(false, true); + + /** guarded: instances built with search: false have no $searchInput to write to */ + if (vs.$searchInput) { + vs.setSearchValue(''); + } + + vs.$optionsContainer.scrollTop = 0; + vs.closeDropbox(); + }); + + cy.getVs(id).find('.vscomp-wrapper').should('have.class', 'closed'); + cy.getVs(id).click(); + cy.getVs(id).find('.vscomp-wrapper').should('not.have.class', 'closed'); + cy.getDropbox(null, id).find('.vscomp-option.focused').should('not.exist'); + + cy.getVs(id).should(($ele) => { + const vs = $ele[0].virtualSelect; + + expect(vs.searchValue, 'search value').to.equal(''); + expect(vs.$optionsContainer.scrollTop, 'options scrollTop').to.equal(0); + }); + + cy.getVs(id); +}); + Cypress.Commands.add('close', { prevSubject: true }, (vsElem) => { cy.get(vsElem).click(); }); @@ -121,18 +178,32 @@ Cypress.Commands.add('typeValue', { prevSubject: true }, (vsElem, value, clearTe }); Cypress.Commands.add('pressKeys', { prevSubject: true }, (vsElem, keys) => { - const searchInput = cy.getDropbox(vsElem).find('.vscomp-search-input'); - searchInput.focus(); + /** + * Focus the search input when there is one and the wrapper otherwise. + * + * This used to resolve `.vscomp-search-input` unconditionally, so any `search: false` instance + * failed with an opaque "expected to find element" - the very case `cy.openFresh()` guards + * above. The wrapper is the right fallback rather than a workaround: it carries + * `role="combobox"` and `tabindex="0"`, and it is where `onKeyDown` is actually bound + * (`$allWrappers`, src/virtual-select.js:589), which is how a keystroke in the search input + * reaches the handler in the first place - by bubbling up to it. + */ + cy.get(vsElem).then(($ele) => { + const vs = $ele[0].virtualSelect; + + cy.wrap(vs.$searchInput || vs.$wrapper).focus(); + }); const keysToPress = Array.isArray(keys) ? keys : [keys]; - + keysToPress.forEach(key => { if (isValidKey(key)) { // TypeScript now knows this is a valid key type cy.realPress(key); } else { - // Log an error or fail the test if an invalid key is passed - throw new Error(`Invalid key provided: "${key}". Must be one of: ${keysToPress.join(', ')}`); + /** the *valid* set - this used to interpolate the caller's own keys, so an invalid key + * produced `Invalid key provided: "Foo". Must be one of: Foo` */ + throw new Error(`Invalid key provided: "${key}". Must be one of: ${SPECIAL_KEYS.join(', ')}`); } }); cy.get(vsElem); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index ad1258f3..fd2cae73 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -1,5 +1,26 @@ +/** + * VirtualSelect.setEleProps() attaches the instance and its public API directly onto the + * host element, so specs can drive a component through the same surface consumers use. + */ interface HTMLElement { virtualSelect?: any; + reset?: (formReset?: boolean, disableChangeEvent?: boolean) => void; + setValue?: (value: any, options?: any) => void; + setOptions?: (options: any[], keepValue?: boolean) => void; + setDisabledOptions?: (disabledOptions: any[], keepValue?: boolean) => void; + setEnabledOptions?: (enabledOptions: any[], keepValue?: boolean) => void; + toggleSelectAll?: (isSelectAll?: boolean) => void; + isAllSelected?: () => boolean; + addOption?: (data: any, rerender?: boolean) => void; + getNewValue?: () => any; + getDisplayValue?: () => any; + getSelectedOptions?: (options?: any) => any; + getDisabledOptions?: () => any; + open?: () => void; + close?: () => void; + destroy?: () => void; + validate?: () => boolean; + toggleRequired?: (isRequired: boolean) => void; } declare namespace Cypress { @@ -29,6 +50,15 @@ declare namespace Cypress { */ open(id: string): Chainable; + /** + * Open from a known state - closed, no value, no highlighted option - so press counts do + * not depend on what the previous test left behind. + * + * @example + * cy.openFresh('option-group-select') + */ + openFresh(id: string): Chainable; + /** * @example close() diff --git a/cypress/support/mount.ts b/cypress/support/mount.ts new file mode 100644 index 00000000..e8480291 --- /dev/null +++ b/cypress/support/mount.ts @@ -0,0 +1,48 @@ +/** cSpell:ignore vscomp */ + +/** + * Shared mount helper for the accessibility/security regression specs. + * + * The docs site is a docsify SPA, so these specs attach their own throwaway host element + * rather than relying on a demo instance whose props may change with the documentation. + * Mounting per-test keeps each assertion tied to an explicit, minimal configuration. + */ + +export type VsOptions = Record; + +/** Remove a previously mounted host (and its instance) so specs can re-mount idempotently. */ +export function unmountVs(win: Window, mountId: string): void { + const existing = win.document.getElementById(mountId); + + if (!existing) { + return; + } + + // Destroy first: an orphaned instance keeps global listeners registered. + const instance = (existing as unknown as { virtualSelect?: { destroy: () => void } }).virtualSelect; + instance?.destroy(); + existing.remove(); +} + +/** + * Create a fresh host element and initialise a VirtualSelect on it. + * + * @returns the host element the instance was mounted on + */ +export function mountVs(win: Window, mountId: string, options: VsOptions): HTMLElement { + unmountVs(win, mountId); + + const $ele = win.document.createElement('div'); + $ele.id = mountId; + win.document.body.appendChild($ele); + + // @ts-expect-error - VirtualSelect is attached to window by the bundle + win.VirtualSelect.init({ ele: $ele, ...options }); + + return $ele; +} + +/** Build a simple list of `count` options, values `o1..oN`. */ +export function makeOptions(count: number): Array<{ label: string; value: string }> { + return Array.from({ length: count }, (_, i) => ({ label: `Option ${i + 1}`, value: `o${i + 1}` })); +} diff --git a/docs/methods.md b/docs/methods.md index f2297014..c21c9f1c 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -22,6 +22,7 @@ - [setServerOptions()](#setserveroptions) - [validate()](#validate) - [toggleRequired()](#togglerequired) +- [VirtualSelect.setGlobalDefaults()](#virtualselectsetglobaldefaults) ### Get selected value @@ -268,3 +269,51 @@ To update required property value ```js document.querySelector('#sample-select').toggleRequired(true); ``` + +### VirtualSelect.setGlobalDefaults() + +Set default props applied to every instance created afterwards, so a page-wide policy does not +have to be repeated at each call site. + +The main use is security. Option `label` and `description` are inserted as raw HTML and are only +escaped when `enableSecureText` is on, which is **not** the default (escaping runs per option and +is measurable on 10k-100k+ lists). `value` is never rendered as HTML - it is only compared and +written to a `data-value` attribute, which is escaped at that boundary - so it stays exactly as +you supplied it. If any option text in your app can come from untrusted input, turn escaping on +once during startup: + +```js +VirtualSelect.setGlobalDefaults({ enableSecureText: true }); +``` + +Notes: + +- These are **defaults, not overrides**. An instance that passes the prop explicitly still wins, + so if your wrapper forwards `enableSecureText` on every `init()` call it must stop doing so (or + forward `true`) for the global to take effect. +- Only instances created **after** the call are affected. Call it before initialising dropdowns. +- Calls **merge**, so unrelated settings can be configured separately. +- `ele` and `options` are ignored, being inherently per-instance. +- A non-object argument (e.g. an accidentally-unset variable) is **ignored**, so a page-wide + policy cannot be wiped by mistake. To clear a single key, pass it with the value `undefined`; + to clear everything, call [`VirtualSelect.resetGlobalDefaults()`](#virtualselectresetglobaldefaults). + +### VirtualSelect.resetGlobalDefaults() + +Drop every global default, so instances created afterwards fall back to the library's own +defaults. Clearing is deliberately a separate method: `setGlobalDefaults()` only ever merges, so +passing it `{}` does nothing. + +```js +VirtualSelect.resetGlobalDefaults(); +``` + +### VirtualSelect.getGlobalDefaults() + +Read the global defaults currently in force, as set by +[`setGlobalDefaults()`](#virtualselectsetglobaldefaults). Returns a shallow copy, so writing to +the returned object does not change what later instances get. + +```js +VirtualSelect.getGlobalDefaults(); +``` diff --git a/docs/properties.md b/docs/properties.md index 3b1aef21..2f8c1459 100644 --- a/docs/properties.md +++ b/docs/properties.md @@ -2,11 +2,16 @@ > ### ⚠️ Security: option text is rendered as HTML > -> By default (`enableSecureText: false`) option **labels and values are inserted as raw HTML and are NOT escaped**. If any option text (label, value, description, or `customData` used in markup) can come from untrusted input, this is an **XSS risk**. +> By default (`enableSecureText: false`) option **labels and descriptions are inserted as raw HTML and are NOT escaped**. If any option text (label, description, or `customData` used in markup) can come from untrusted input, this is an **XSS risk**. > -> - Set **`enableSecureText: true`** whenever options may contain user-supplied/untrusted text. It escapes the built-in option fields (**label**, **value**, and **description**). Note: `customData` is stored as-is (except where the library itself interpolates it, e.g. into the option `aria-label`), and any HTML returned by `labelRenderer`/`selectedLabelRenderer` is **not** sanitized — escape those yourself. +> - Set **`enableSecureText: true`** whenever options may contain user-supplied/untrusted text. It escapes the option fields that are rendered as HTML — **label** and **description**. (**value** is not escaped: it is never rendered as HTML, only compared and written to a `data-value` attribute, which is escaped at that boundary.) Note: `customData` is stored as-is (except where the library itself interpolates it, e.g. into the option `aria-label`), and any HTML returned by `labelRenderer`/`selectedLabelRenderer` is **not** sanitized — escape those yourself. > - It is **off by default on purpose**: escaping runs per option and adds measurable cost on **large datasets (10k–100k+ records)**. For large lists of *trusted, developer-controlled* data you can leave it off. > - When it is off, the plugin logs a **one-time** console warning (per page) on initialization so the trade-off is discoverable. Set **`showSecureTextWarning: false`** to suppress it once you have consciously accepted the trade-off. +> - To turn escaping on for **every** dropdown at once instead of per call site, use [`VirtualSelect.setGlobalDefaults({ enableSecureText: true })`](methods.md#virtualselectsetglobaldefaults) during startup. Note it sets a *default*: an instance passing `enableSecureText` explicitly still wins. +> - **What escaping does and does not touch.** `enableSecureText: true` escapes only the two fields that are rendered as HTML — **`label` and `description`** — so `option.label` may read back as `Tom & Jerry` where you supplied `Tom & Jerry`. It renders correctly; only a direct read of the field shows the escaped form. +> - **`option.value` is always stored exactly as you supplied it**, escaping on or off. It is never rendered as HTML, so escaping it would only make the option impossible to address — `setValue()`, `setDisabledOptions()` and `getSelectedOptions()` all take and return your own value. +> - **Search always matches the text you supplied**, not the escaped form, so quotes, `&`, `<` and `>` in a label or description are all searchable. +> - Since 1.4.0, quotes are escaped at each attribute the library writes rather than in the stored text, so quoted text stores, renders and searches verbatim. | Name | Type | Default value | Description | | --------------------------------- | ----------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -74,7 +79,7 @@ | disableAllOptionsSelectedText | Boolean | false | By default, when all values selected "All (10)" value text would be shown. Set true to show value text as "10 options selected". | | showValueAsTags | Boolean | false | Show each selected values as tags with remove icon | | disableOptionGroupCheckbox | Boolean | false | Disable option group title checkbox | -| enableSecureText | Boolean | false | **Security:** set `true` to escape the built-in option fields (label, value, and description) and prevent XSS. **Off by default** to avoid the per-option escaping cost on large datasets (10k–100k+ records); enable it whenever option text may contain untrusted input. Note: `customData` is stored as-is (except where the library itself interpolates it) and HTML returned by `labelRenderer`/`selectedLabelRenderer` is **not** sanitized. While off, option text is rendered as raw HTML and a one-time console warning is logged. See the security note at the top of this page. | +| enableSecureText | Boolean | false | **Security:** set `true` to escape the option fields rendered as HTML (label and description) and prevent XSS. `value` is never escaped — it reaches no HTML sink, so it stays exactly as you supplied it and remains usable with `setValue()`. **Off by default** to avoid the per-option escaping cost on large datasets (10k–100k+ records); enable it whenever option text may contain untrusted input. Note: `customData` is stored as-is (except where the library itself interpolates it) and HTML returned by `labelRenderer`/`selectedLabelRenderer` is **not** sanitized. While off, option text is rendered as raw HTML and a one-time console warning is logged. See the security note at the top of this page. | | showSecureTextWarning | Boolean | true | Whether to log the one-time console warning that fires when `enableSecureText` is disabled. Set `false` to suppress it once you have **consciously accepted** the XSS trade-off (trusted/developer-controlled option data or intentional HTML). Leaving it on is recommended so the trade-off stays discoverable. | | setValueAsArray | Boolean | false | Set value for hidden input in array format (e.g. '["1", "2"]') | | emptyValue | String | | Empty value to use for hidden input when no value is selected (e.g. 'null' or '[]' or 'none') | @@ -105,6 +110,13 @@ Update below properties to change display texts. | optionsSelectedText | String | options selected | Text to use when displaying no.of values selected text (i.e. 3 options selected) | | optionSelectedText | String | option selected | Text to use when displaying no.of values selected text and only one value is selected (i.e. 1 option selected) | | allOptionsSelectedText | String | All | Text to use when displaying all values selected text (i.e. All (10)) | +| searchResultsText | String | results available | Announced in the live region after a search (i.e. 5 results available) | +| searchResultText | String | result available | Singular form of `searchResultsText` (i.e. 1 result available) | +| noOptionsSelectedText | String | No options selected | Announced in the live region when the selection becomes empty | +| selectedText | String | selected | Announced in the live region after a single select choice (i.e. Option 3 selected) | +| loadingText | String | Loading results | Announced in the live region while a server search is in flight | +| requiredErrorText | String | This field is required | Validation message shown and announced when a required select has no value | +| minValuesErrorText | String | Select at least {count} options | Validation message when fewer than minValues are selected; {count} is replaced with minValues | | clearButtonText | String | Clear | Tooltip text for clear button | | moreText | String | more... | Text to show when more than noOfDisplayValues options selected (i.e + 10 more...) | diff --git a/src/sass/partials/variable.scss b/src/sass/partials/variable.scss index 28334fa3..e7bd48eb 100644 --- a/src/sass/partials/variable.scss +++ b/src/sass/partials/variable.scss @@ -19,7 +19,9 @@ $search-height: 40px; $checkbox-full-width: 25px; $new-option-icon-width: 30px; $search-clear-width: 30px; -$value-tag-clear-width: 20px; +/** WCAG 2.5.8 Target Size (Minimum), AA - floor for pointer targets */ +$min-target-size: 24px; +$value-tag-clear-width: $min-target-size; $option-height: 40px; $arrow-width: 30px; $arrow-size: 8px; diff --git a/src/sass/partials/virtual-select.scss b/src/sass/partials/virtual-select.scss index d61d1c01..88be7466 100755 --- a/src/sass/partials/virtual-select.scss +++ b/src/sass/partials/virtual-select.scss @@ -321,6 +321,25 @@ } } +.vscomp-error-message { + align-items: flex-start; + color: v.$error-color; + display: none; + font-size: v.$font-size-small; + gap: 4px; + line-height: 1.4; + padding-top: 4px; + width: 100%; + + /** shape cue, so the failure is not signalled by colour alone */ + &::before { + content: '\26A0'; + flex: none; + font-size: 1.1em; + line-height: 1.2; + } +} + .vscomp-no-options, .vscomp-no-search-results { align-items: center; @@ -427,6 +446,14 @@ align-items: center; cursor: pointer; display: flex; + /** + * WCAG 2.5.8: the button previously collapsed to its 25x15 content box. + * A min-* floor grows the hit area without scaling the checkbox glyph inside it. + * No justify-content override: the checkbox must stay left-aligned with the + * option checkboxes below it, not centered in the button's full width. + */ + min-height: v.$min-target-size; + min-width: v.$min-target-size; } .vscomp-search-input, @@ -572,6 +599,17 @@ .vscomp-toggle-button { border-color: v.$error-color; } + + /** + * The message is the non-colour cue required by WCAG 1.4.1: the border tint alone + * carried the whole meaning before. The leading glyph adds a second, shape-based cue. + * + * A sibling selector because the message lives *outside* the wrapper: inside it, the + * text would join the combobox's name-from-contents computation (see renderWrapper). + */ + ~ .vscomp-error-message { + display: flex; + } } &.show-value-as-tags { @@ -654,3 +692,19 @@ } } } + +/** + * Users who ask the operating system to reduce motion should not get the dropbox slide. + * + * The loader spin is deliberately left alone: it is a status indicator rather than decoration, + * and a frozen spinner reads as a broken UI instead of "working". Screen reader users get the + * same information from the live region, so nothing is lost by keeping it. + */ +@media (prefers-reduced-motion: reduce) { + .vscomp-dropbox-container, + .vscomp-dropbox, + .vscomp-wrapper .vscomp-toggle-button, + .vscomp-dropbox-wrapper { + transition-duration: 0s !important; + } +} diff --git a/src/utils/dom-utils.js b/src/utils/dom-utils.js index 330e6b6a..b8b1ec8a 100644 --- a/src/utils/dom-utils.js +++ b/src/utils/dom-utils.js @@ -132,6 +132,44 @@ export class DomUtils { $ele.setAttribute(name, value); } + /** + * @param {HTMLElement} $ele + * @param {string} name + */ + static removeAttr($ele, name) { + if (!$ele) { + return; + } + + $ele.removeAttribute(name); + } + + /** + * Set an aria-* attribute when the state applies, remove it otherwise. + * + * Preferred over writing `aria-x="false"` for states whose absence is meaningful + * (aria-required, aria-invalid): a literal "false" is valid but adds noise that some + * screen readers still verbalise. + * + * @param {HTMLElement | NodeListOf} $ele + * @param {string} name + * @param {boolean} isSet + * @param {string} [value='true'] + */ + static toggleAria($ele, name, isSet, value = 'true') { + if (!$ele) { + return; + } + + DomUtils.getElements($ele).forEach(($this) => { + if (isSet) { + $this.setAttribute(`aria-${name}`, value); + } else { + $this.removeAttribute(`aria-${name}`); + } + }); + } + /** * @param {HTMLElement} $from * @param {HTMLElement} $to @@ -290,7 +328,21 @@ export class DomUtils { // @ts-ignore Object.entries(data).forEach(([k, v]) => { if (v !== undefined) { - html += ` ${k}="${v}" `; + /** + * Quotes are escaped here, at the attribute boundary, because this is the only place + * that knows the value is about to be wrapped in double quotes. + * + * The caller that matters is getTooltipAttrText(), which passes option labels through + * to data-tooltip. It used to escape quotes itself but only when containsHTML(label) + * was true, so a payload with no tag in it - `x" data-pwned="1" z="` - went in raw and + * put a live attribute on the value-tag element. Escaping unconditionally here removes + * the condition, and the parser turns " back into a quote, so the attribute still + * reads back as the original string. + * + * String(), not Utils.getString(): the latter maps `false` to '', and some of these + * attributes carry real boolean values (data-tooltip-ellipsis-only, -allow-html). + */ + html += ` ${k}="${Utils.replaceDoubleQuotesWithHTML(String(v))}" `; } }); diff --git a/src/utils/utils.js b/src/utils/utils.js index 5dd677ba..485d4370 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -195,17 +195,78 @@ export class Utils { * @memberof Utils */ static willTextOverflow(container, text) { - const tempElement = document.createElement('div'); - tempElement.style.position = 'absolute'; - tempElement.style.visibility = 'hidden'; - tempElement.style.whiteSpace = 'nowrap'; - tempElement.style.fontSize = window.getComputedStyle(container).fontSize; - tempElement.style.fontFamily = window.getComputedStyle(container).fontFamily; - tempElement.textContent = text; - document.body.appendChild(tempElement); - const textWidth = tempElement.clientWidth; - document.body.removeChild(tempElement); - return textWidth > container.clientWidth; + /** + * Called once per selected tag to decide whether that tag needs a tooltip. + * + * It used to create a div, read two separate getComputedStyle results, append it to + * , read clientWidth and remove it again - so every tag paid an element creation + * plus two DOM mutations, and each mutation invalidates layout for the read that + * follows. Rendering many tags therefore meant a burst of forced synchronous layouts. + * + * One reusable off-screen node instead, and one getComputedStyle read for every property. + * The node stays out of flow and is aria-hidden, so it cannot affect layout or be + * announced, and it is removed once the last instance is destroyed. + */ + const $measurer = Utils.getTextMeasurer(); + const { fontSize, fontFamily, fontWeight, letterSpacing } = window.getComputedStyle(container); + + $measurer.style.fontSize = fontSize; + $measurer.style.fontFamily = fontFamily; + /** weight and tracking change advance width too, so ignoring them under-reported + * overflow and could drop a tooltip that was actually needed */ + $measurer.style.fontWeight = fontWeight; + $measurer.style.letterSpacing = letterSpacing; + $measurer.textContent = text; + + return $measurer.clientWidth > container.clientWidth; + } + + /** + * Whether the user has asked the operating system to reduce motion. + * + * Read on each call rather than cached, so a preference changed after page load is picked up + * by the next instance. Guarded for environments without matchMedia. + * + * @static + * @returns {boolean} + */ + static prefersReducedMotion() { + return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + /** + * The shared, lazily created off-screen node used to measure text width. + * + * @static + * @returns {HTMLElement} + */ + static getTextMeasurer() { + if (!Utils.$textMeasurer || !Utils.$textMeasurer.isConnected) { + const $measurer = document.createElement('div'); + + $measurer.className = 'vscomp-text-measurer'; + $measurer.setAttribute('aria-hidden', 'true'); + $measurer.style.cssText = + 'position:absolute;top:0;left:-9999px;visibility:hidden;white-space:nowrap;pointer-events:none;'; + + document.body.appendChild($measurer); + Utils.$textMeasurer = $measurer; + } + + return Utils.$textMeasurer; + } + + /** + * Drop the shared measuring node, so nothing of ours is left in the document once the last + * instance has gone. + * + * @static + */ + static removeTextMeasurer() { + if (Utils.$textMeasurer) { + Utils.$textMeasurer.remove(); + Utils.$textMeasurer = null; + } } /** @@ -218,6 +279,90 @@ export class Utils { return text.replace(/"/g, '"'); } + /** + * Escape a *raw* string for interpolation into a double-quoted HTML attribute. + * + * Use this only where the input has not already been HTML-escaped - currently the option + * value, which is stored verbatim because it reaches no innerHTML sink. `&` must be escaped + * first, otherwise the `&` introduced by the quote replacement would itself be escaped and + * the attribute would parse back as a literal `"`. + * + * Deliberately NOT used by DomUtils.getAttributesText(), whose inputs are already-escaped + * label text: escaping `&` there would double it and a tooltip would show `&`. That + * asymmetry is the reason this is a separate helper rather than a shared one. + * + * @static + * @param {string} text + * @return {string} + * @memberof Utils + */ + static escapeAttributeValue(text) { + return Utils.getString(text).replace(/&/g, '&').replace(/"/g, '"'); + } + + /** + * Undo the escaping secureText() applies, recovering the text a human should read. + * + * Option label and description are stored HTML-escaped when enableSecureText is on, because + * they are inserted as HTML. Anywhere that text is consumed as *text* instead - an accessible + * name, a live-region announcement - the escape sequences have to come back off, or the user + * is read `&` and `<i class=...`. + * + * `&` is decoded last: doing it first would turn a literal `&lt;` into `<` and then + * into `<`, inventing markup the consumer never wrote. + * + * @static + * @param {string} text + * @return {string} + * @memberof Utils + */ + static decodeSecureText(text) { + return Utils.getString(text) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, '\u00A0') + .replace(/&/g, '&'); + } + + /** + * Reduce label text to the words a human should hear or read. + * + * Labels may legitimately contain markup - an icon, , a
. Wherever that text is + * consumed as text (an accessible name, a live-region announcement) the markup is meaningless + * and is read out as tag soup. Tags collapse to a single space so adjacent words do not run + * together, so "France
Paris" does not become one word. + * + * The escaping is undone first. With enableSecureText on the label arrives already escaped, so + * the tag pattern found no `<` to match and the markup passed through verbatim - the strip was + * a no-op in exactly the mode escaping is enabled in. + * + * @static + * @param {string} text + * @return {string} + * @memberof Utils + */ + static getPlainText(text) { + return Utils.decodeSecureText(text) + .replace(/<[^>]+>/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Turn a label into text that is safe and sensible inside an aria-label attribute. + * + * Plain text, then escaped for the attribute - covering `&` as well as `"`, so a bare + * ampersand in a label is a valid character reference rather than raw markup, and a double + * quote can no longer close the attribute early and truncate the name. + * + * @static + * @param {string} text + * @returns {string} + */ + static getAriaLabelText(text) { + return Utils.escapeAttributeValue(Utils.getPlainText(text)); + } + /** * @static * @param {string} text @@ -328,3 +473,10 @@ export class Utils { return throttled; } } + +/** + * Shared off-screen node used to measure text width, created on first use and removed when + * the last VirtualSelect instance is destroyed. + * @type {HTMLElement | null} + */ +Utils.$textMeasurer = null; diff --git a/src/virtual-select.js b/src/virtual-select.js index 523cbd45..cb040ed1 100644 --- a/src/virtual-select.js +++ b/src/virtual-select.js @@ -57,8 +57,11 @@ const dataProps = [ 'maxValues', 'maxWidth', 'minValues', + 'loadingText', + 'minValuesErrorText', 'moreText', 'noOfDisplayValues', + 'noOptionsSelectedText', 'noOptionsText', 'noSearchResultsText', 'optionHeight', @@ -68,6 +71,7 @@ const dataProps = [ 'popupDropboxBreakpoint', 'popupPosition', 'position', + 'requiredErrorText', 'search', 'searchByStartsWith', 'searchDelay', @@ -75,8 +79,11 @@ const dataProps = [ 'searchGroup', 'searchNormalize', 'searchPlaceholderText', + 'searchResultText', + 'searchResultsText', 'selectAllOnlyVisible', 'selectAllText', + 'selectedText', 'setValueAsArray', 'showDropboxAsPopup', 'showOptionsOnlyOnSearch', @@ -128,9 +135,24 @@ export class VirtualSelect { let toggleButtonClasses = 'vscomp-toggle-button'; const valueTooltip = this.showValueAsTags ? '' : this.getTooltipAttrText(this.placeholder, true, true); const clearButtonTooltip = this.getTooltipAttrText(this.clearButtonText); - const ariaLabelledbyText = this.ariaLabelledby ? `aria-labelledby="${this.ariaLabelledby}"` : ''; - const ariaLabelText = this.ariaLabelText ? `aria-label="${this.ariaLabelText}"` : ''; - const ariaLabelClearBtnTxt = this.ariaLabelClearButtonText ? `aria-label="${this.ariaLabelClearButtonText}"` : ''; + /** + * These props are developer-supplied but still reach an attribute directly, and none of them + * passes through secureText() - so enableSecureText never protected them. A double quote + * closed the attribute early: the payload after it was parsed as markup, and the accessible + * name kept only the prefix, which is a WCAG 4.1.2 defect as much as an injection. + * + * getAriaLabelText() for the accessible names, because it is what AI-14 already applies to + * option and group labels: strip markup, then escape quotes. Plain quote escaping for + * aria-labelledby, which is an IDREF list rather than prose - stripping tags there would hide + * a caller error instead of fixing it. + */ + const ariaLabelledbyText = this.ariaLabelledby + ? `aria-labelledby="${Utils.replaceDoubleQuotesWithHTML(Utils.getString(this.ariaLabelledby))}"` + : ''; + const ariaLabelText = this.ariaLabelText ? `aria-label="${Utils.getAriaLabelText(this.ariaLabelText)}"` : ''; + const ariaLabelClearBtnTxt = this.ariaLabelClearButtonText + ? `aria-label="${Utils.getAriaLabelText(this.ariaLabelClearButtonText)}"` + : ''; let isExpanded = false; if (this.additionalClasses) { @@ -183,7 +205,7 @@ export class VirtualSelect { `
- +
${this.placeholder} @@ -196,7 +218,12 @@ export class VirtualSelect {
${this.renderDropbox({ wrapperClasses })} -
`; +
+ +
+ +
`; this.$ele.innerHTML = html; this.$body = document.querySelector('body'); @@ -216,6 +243,33 @@ export class VirtualSelect { this.$clearButton = this.$ele.querySelector('.vscomp-clear-button'); this.$valueText = this.$ele.querySelector('.vscomp-value'); this.$hiddenInput = this.$ele.querySelector('.vscomp-hidden-input'); + + /** + * The submitting field's name is set as a DOM property, not interpolated into the template. + * + * `name="${this.name}"` made the attribute an HTML sink: a double quote closed it early, so + * the remainder of the value was parsed as markup (real elements, an injection) while the + * field kept only the truncated prefix - or, when the payload also swallowed the following + * `class="vscomp-hidden-input"`, no field was found at all and the first setValue() threw + * inside the constructor. Either way the form silently stopped submitting the right name, + * and that included legitimate names such as `items["a"]`. + * + * A property assignment performs no HTML parsing, so there is nothing to break out of and + * nothing to escape - which is also why `name` no longer goes through secureText(): the + * escaping only ever protected this sink, and applying it here corrupted the submitted + * field name into `items["a"]`. + */ + this.$hiddenInput.name = this.name; + + /** + * Both live outside the wrapper, as siblings, because the wrapper is the combobox: for an + * instance mounted without ariaLabelText/ariaLabelledby the combobox takes its accessible + * name from its contents, and visually-hidden text still joins that computation - so a + * status update or validation message inside it would be read as part of the field's *name*. + * A sibling can be announced (live region) or associated (aria-describedby) without that. + */ + this.$liveRegion = this.$ele.querySelector('.vscomp-live-region'); + this.$errorMessage = this.$ele.querySelector('.vscomp-error-message'); this.$dropbox = this.$dropboxContainer.querySelector('.vscomp-dropbox'); this.$dropboxCloseButton = this.$dropboxContainer.querySelector('.vscomp-dropbox-close-button'); this.$dropboxContainerBottom = this.$dropboxContainer.querySelector('.vscomp-dropbox-container-bottom'); @@ -249,7 +303,9 @@ export class VirtualSelect {
-
+
@@ -290,8 +346,16 @@ export class VirtualSelect { } renderOptions() { - // Calculate ARIA metadata before rendering to ensure it's always up to date - this.calculateAriaMetadata(); + /** + * The ARIA scan walks every option, so running it per render made scrolling O(n) per + * event (~3.9 ms/call at 100k). aria-setsize/aria-posinset only change when the + * filtered set or its order changes, never when the virtualisation window moves, so + * recompute on a dirty flag instead. Everything that alters the set marks it dirty. + */ + if (this.ariaMetadataDirty) { + this.calculateAriaMetadata(); + this.ariaMetadataDirty = false; + } let html = ''; const visibleOptions = this.getVisibleOptions(); @@ -354,15 +418,15 @@ export class VirtualSelect { } if (d.isGroupTitle) { - groupName = d.label; + /** carried into every child's aria-label below, so strip markup once here */ + groupName = Utils.getAriaLabelText(d.label); optionClasses += ' group-title'; if (disableOptionGroupCheckbox) { leftSection = ''; } else if (this.multiple) { - const groupLabel = Utils.replaceDoubleQuotesWithHTML(Utils.getString(d.label)); - const selectAllText = Utils.replaceDoubleQuotesWithHTML(Utils.getString(this.selectAllText)); - ariaLabel = `aria-label="${groupLabel}, ${selectAllText}"`; + const selectAllText = Utils.getAriaLabelText(this.selectAllText); + ariaLabel = `aria-label="${groupName}, ${selectAllText}"`; } } @@ -382,15 +446,15 @@ export class VirtualSelect { * is on - an XSS bypass. secureText is a no-op when enableSecureText is disabled, * keeping the existing behaviour for consumers that intentionally pass raw text. */ - const groupNameText = this.secureText(Utils.getString(d.customData.group_name)); - const groupDescText = this.secureText(Utils.getString(d.customData.description)); + const groupNameText = Utils.getAriaLabelText(this.secureText(Utils.getString(d.customData.group_name))); + const groupDescText = Utils.getAriaLabelText(this.secureText(Utils.getString(d.customData.description))); groupName = d.customData.group_name !== undefined ? `${groupNameText}, ` : ''; const optionDesc = d.customData.description !== undefined ? ` ${groupDescText},` : ''; - ariaLabel = `aria-label="${groupName} ${d.label}, ${optionDesc}"`; + ariaLabel = `aria-label="${groupName} ${Utils.getAriaLabelText(d.label)}, ${optionDesc}"`; } else { - ariaLabel = `aria-label="${groupName}, ${d.label}"`; + ariaLabel = `aria-label="${groupName}, ${Utils.getAriaLabelText(d.label)}"`; } } @@ -420,8 +484,23 @@ export class VirtualSelect { } } + /** + * The option value is an untrusted string going straight into an attribute, so a double + * quote in it closed data-value early and everything after it was parsed as markup - a + * value of `x" data-pwned="1" z="` put a live data-pwned attribute on the option row, + * whether or not enableSecureText was on. + * + * `&` is escaped here as well as `"`, because the value is now stored verbatim (it reaches + * no innerHTML sink, so escaping it only made the option unaddressable - see secureText). + * Both together keep the attribute round-tripping: the parser turns `&` and `"` + * back into `&` and `"`, and setOptionAttr() rewrites data-value through the DOM API on + * every render anyway. + */ + const optionValueAttr = Utils.escapeAttributeValue(d.value); + html += `
${leftSection} @@ -450,7 +529,24 @@ export class VirtualSelect { let searchInput = ''; if (this.multiple && !this.disableSelectAll) { - checkboxHtml = ` + /** + * role="checkbox" + aria-checked so the control is announced as a checkbox and its + * state changes are audible. Without them it exposed as a generic element and every + * select/deselect was silent to assistive technology (WCAG 4.1.2 / 1.3.1). + * aria-checked is kept in sync by toggleAllOptionsClass(). + */ + /** + * selectAllText has two sinks, and only the attribute one is escaped. + * + * The visible label below is rendered as HTML and that works today - `Pick all` + * produces a real - so escaping it would be a visible regression for anyone styling + * the Select All label. The accessible name, by contrast, was raw: a quote broke out of + * the attribute, and markup was announced as tag soup. getAriaLabelText() is the same + * treatment the group-header aria-label a few methods up already applies to this exact + * prop, which is why that sink was safe while this one was not. + */ + checkboxHtml = ` ${this.selectAllText} `; @@ -458,7 +554,7 @@ export class VirtualSelect { if (this.hasSearch) { const ariaLabelSearchClearBtnTxt = this.ariaLabelSearchClearButtonText - ? `aria-label="${this.ariaLabelSearchClearButtonText}"` + ? `aria-label="${Utils.getAriaLabelText(this.ariaLabelSearchClearButtonText)}"` : ''; searchInput = ` - + ×`; } @@ -614,14 +713,28 @@ export class VirtualSelect { this.focusFirstVisibleOption(); } - if (document.activeElement === this.$toggleAllButton && key === 13) { + /** + * Space is the expected activation key for role="checkbox"; Enter is kept for + * backwards compatibility. preventDefault stops Space from scrolling the page + * (the previous behaviour, since the key was unhandled here). + */ + if (document.activeElement === this.$toggleAllButton && (key === 13 || key === 32)) { + e.preventDefault(); this.toggleAllOptions(); return; } - // Handle the Escape key when showing the dropdown as a popup, closing it + /** + * Escape must close the dropdown in every layout (WCAG 2.1.1 / 2.1.2). + * The element that contains the focused node differs by layout: with an external + * `dropboxWrapper` the dropbox is portalled out of $wrapper, so containment has to be + * tested against $dropboxWrapper. In every other case - including the default + * `dropboxWrapper: 'self'` on desktop - the dropbox lives inside $wrapper. Selecting + * $dropboxWrapper unconditionally for non-popup layouts left it `undefined` under the + * default config, so the branch never ran and Escape did nothing. + */ if (key === 27 || e.key === 'Escape') { - const wrapper = this.showAsPopup ? this.$wrapper : this.$dropboxWrapper; + const wrapper = this.hasDropboxWrapper && !this.showAsPopup ? this.$dropboxWrapper : this.$wrapper; if ( wrapper && (document.activeElement === wrapper || wrapper.contains(document.activeElement)) && @@ -647,32 +760,34 @@ export class VirtualSelect { } } - onDownArrowPress(e) { - // Allow default behavior (cursor movement) when search input is focused - if (document.activeElement === this.$searchInput) { - return; - } + /** + * Move the highlight without moving DOM focus. + * + * Previously both arrow handlers bailed out whenever the search input had focus, to let + * the caret move. But opening the dropdown focuses the search input, so in the default + * flow the arrows did nothing at all and no option was ever highlighted (WCAG 2.1.1). + * The APG editable-combobox pattern is what applies here: Up/Down drive the list while + * focus stays in the field, and the active option is published as aria-activedescendant. + * + * @param {KeyboardEvent} e + * @param {'next' | 'previous'} direction + */ + navigateOptions(e, direction) { e.preventDefault(); if (this.isOpened()) { - this.focusOption({ direction: 'next' }); + this.focusOption({ direction }); } else { this.openDropbox(); } } - onUpArrowPress(e) { - // Allow default behavior (cursor movement) when search input is focused - if (document.activeElement === this.$searchInput) { - return; - } - e.preventDefault(); + onDownArrowPress(e) { + this.navigateOptions(e, 'next'); + } - if (this.isOpened()) { - this.focusOption({ direction: 'previous' }); - } else { - this.openDropbox(); - } + onUpArrowPress(e) { + this.navigateOptions(e, 'previous'); } onBackspaceOrDeletePress(e) { @@ -716,8 +831,26 @@ export class VirtualSelect { } } + /** + * Scroll fires many times per drag and each event triggered a full re-render + * (~9.5 ms at 100k unthrottled, ~44 ms at 4x CPU), so the main thread stayed blocked for + * the whole gesture. Coalesce into at most one re-render per animation frame; the pending + * frame is cancelled in destroy() so it cannot run against a torn-down instance. + */ onOptionsScroll() { - this.setVisibleOptions(true); + if (this.scrollAnimationFrame) { + return; + } + + this.scrollAnimationFrame = requestAnimationFrame(() => { + this.scrollAnimationFrame = null; + + if (this.isDestroyed) { + return; + } + + this.setVisibleOptions(true); + }); } onOptionsClick(e) { @@ -902,6 +1035,8 @@ export class VirtualSelect { if (VirtualSelect.activeInstances.size === 0) { VirtualSelect.removeGlobalListeners(); VirtualSelect.disconnectDomObserver(); + /** the shared text measurer is the last page-level node we own */ + Utils.removeTextMeasurer(); } } @@ -976,6 +1111,12 @@ export class VirtualSelect { if (this.autofocus) { this.focus(); } + + /** + * Marks the end of construction. Live-region announcements are suppressed until + * here so an initial value or the first render does not speak on page load. + */ + this.isInitialized = true; } catch (e) { this.destroy(); throw e; @@ -1042,7 +1183,14 @@ export class VirtualSelect { this.toggleAllOptionsClass(); } - this.focusOption({ focusFirst: true }); + /** a closing dropbox must not take a highlight back - see closeDropbox() */ + if (!this.isClosing) { + this.focusOption({ focusFirst: true }); + } + + if (!this.hasServerSearch) { + this.announceSearchResults(); + } } afterSetVisibleOptionsCount() { @@ -1071,7 +1219,20 @@ export class VirtualSelect { } if (!keepValue) { - this.reset(); + /** + * reset() validates, and validation announces - so replacing the options used to speak a + * validation failure for an interaction the user never made. Scoped to this one call rather + * than the whole method, and released in a finally: a stuck flag would silently suppress + * every later error announcement, which is far harder to diagnose than an exception (the + * lesson from the isClosing guard in closeDropbox()). + */ + this.isRefreshingOptions = true; + + try { + this.reset(); + } finally { + this.isRefreshingOptions = false; + } } } /** after event methods - end */ @@ -1134,6 +1295,14 @@ export class VirtualSelect { this.optionsSelectedText = options.optionsSelectedText; this.optionSelectedText = options.optionSelectedText; this.allOptionsSelectedText = options.allOptionsSelectedText; + /** live-region announcement texts (see announce/getResultsCountMessage) */ + this.searchResultsText = options.searchResultsText; + this.searchResultText = options.searchResultText; + this.noOptionsSelectedText = options.noOptionsSelectedText; + this.selectedText = options.selectedText; + this.loadingText = options.loadingText; + this.requiredErrorText = options.requiredErrorText; + this.minValuesErrorText = options.minValuesErrorText; this.clearButtonText = options.clearButtonText; this.moreText = options.moreText; this.placeholder = options.placeholder; @@ -1148,7 +1317,8 @@ export class VirtualSelect { this.zIndex = parseInt(options.zIndex, 10); this.maxValues = parseInt(options.maxValues, 10); this.minValues = parseInt(options.minValues, 10); - this.name = this.secureText(options.name); + /** not escaped: the only sink is the hidden input's `name` *property* (see renderWrapper) */ + this.name = options.name; this.additionalClasses = options.additionalClasses; this.additionalDropboxClasses = options.additionalDropboxClasses; this.additionalDropboxContainerClasses = options.additionalDropboxContainerClasses; @@ -1172,6 +1342,16 @@ export class VirtualSelect { this.showDuration = parseInt(options.showDuration, 10); this.hideDuration = parseInt(options.hideDuration, 10); + /** + * The open/close animation is driven from JS as well as CSS, so the stylesheet's + * prefers-reduced-motion rule alone would still leave the popover animating for + * showDuration/hideDuration milliseconds. Honour the preference here too. + */ + if (Utils.prefersReducedMotion()) { + this.showDuration = 0; + this.hideDuration = 0; + } + /** @type {string[]} */ this.selectedValues = []; /** @type {virtualSelectOption[]} */ @@ -1206,13 +1386,26 @@ export class VirtualSelect { this.optionsHeight = this.getOptionsHeight(); this.uniqueId = this.getUniqueId(); this.shouldFocusWrapperOnClose = true; // Initialize focus management property + this.isClosing = false; this.ariaSetSize = 0; + this.ariaMetadataDirty = true; } /** * @param {virtualSelectOptions} options */ setDefaultProps(options) { + const globalDefaults = VirtualSelect.globalDefaults; + + /** + * Resolve a prop across the precedence chain for the few defaults that are derived + * from another prop, so a page-level default still drives them. + * @param {string} key + */ + const resolve = (key) => (options[key] !== undefined ? options[key] : globalDefaults[key]); + const keepAlwaysOpen = resolve('keepAlwaysOpen'); + const hasOptionDescription = resolve('hasOptionDescription'); + const defaultOptions = { dropboxWrapper: 'self', valueKey: 'value', @@ -1236,10 +1429,19 @@ export class VirtualSelect { moreText: 'more...', optionsSelectedText: 'options selected', optionSelectedText: 'option selected', + /** live-region announcements (WCAG 4.1.3) - overridable for localisation */ + searchResultsText: 'results available', + searchResultText: 'result available', + noOptionsSelectedText: 'No options selected', + selectedText: 'selected', + loadingText: 'Loading results', + /** validation messages; {count} in minValuesErrorText is replaced with minValues */ + requiredErrorText: 'This field is required', + minValuesErrorText: 'Select at least {count} options', allOptionsSelectedText: 'All', placeholder: 'Select', position: 'bottom left', - zIndex: options.keepAlwaysOpen ? 1 : 2, + zIndex: keepAlwaysOpen ? 1 : 2, tooltipFontSize: '14px', tooltipAlignment: 'center', tooltipMaxWidth: '300px', @@ -1262,12 +1464,41 @@ export class VirtualSelect { hideDuration: 200, }; - if (options.hasOptionDescription) { + if (hasOptionDescription) { defaultOptions.optionsCount = 4; defaultOptions.optionHeight = '50px'; } - return Object.assign(defaultOptions, options); + /** + * Precedence: per-instance options > page-level globals > built-in defaults. + * Globals let a host turn a policy on once (notably enableSecureText) instead of + * repeating it at every call site, while an instance can still opt out explicitly. + */ + /** + * `undefined` means "not supplied", so those keys are dropped before merging. + * + * Object.assign copies own enumerable keys *including* ones whose value is undefined, so a prop + * forwarded from an unset variable - `enableSecureText: wrapper.sanitizeValues`, the shape a + * host wrapper naturally produces - overwrote the page-level global instead of falling back to + * it. A host could call setGlobalDefaults({ enableSecureText: true }) and still get escaping + * off at every such call site, with nothing to show it had been overridden. + * + * This also makes the merge agree with the resolve() helper above, which already treats + * undefined as absent; the two disagreed inside the same method. + */ + const supplied = (source) => { + const result = {}; + + Object.keys(source || {}).forEach((key) => { + if (source[key] !== undefined) { + result[key] = source[key]; + } + }); + + return result; + }; + + return Object.assign(defaultOptions, supplied(globalDefaults), supplied(options)); } setPropsFromElementAttr(options) { @@ -1294,6 +1525,8 @@ export class VirtualSelect { $ele.name = this.name; $ele.disabled = false; $ele.required = this.required; + /** expose the constraint itself, not just the failure (WCAG 3.3.1) */ + DomUtils.toggleAria(this.$allWrappers, 'required', this.required); $ele.autofocus = this.autofocus; $ele.multiple = this.multiple; $ele.form = $ele.closest('form'); @@ -1325,8 +1558,23 @@ export class VirtualSelect { } setValueMethod(newValue, silentChange) { - const valuesMapping = {}; - const valuesOrder = {}; + /** + * Option values are untrusted strings used as keys, so every value-keyed lookup in this + * file is built with Object.create(null) rather than `{}`. + * + * This is not about prototype pollution - `mapping['__proto__'] = true` on a plain object + * calls the inherited setter, which ignores a non-object value, so nothing is written and + * Object.prototype stays intact. The damage is to reads: `mapping['__proto__']` returns + * the inherited Object.prototype, which is truthy but never `=== true`, and these lookups + * all compare against `true`. An option whose value is `__proto__` was therefore + * selectable by click (that path reads data-value, not a mapping) but invisible to + * setValue / setDisabledOptions / setEnabledOptions, so a selection the app could read + * back could not be restored - and under allowNewOption it was mistaken for an unknown + * value and duplicated. A null prototype has no inherited members, so an arbitrary string + * key behaves like any other. + */ + const valuesMapping = Object.create(null); + const valuesOrder = Object.create(null); let validValues = []; const isMultiSelect = this.multiple; // Normalize input value first @@ -1395,7 +1643,7 @@ export class VirtualSelect { setGroupOptionsValue(preparedValues) { const selectedValues = []; const selectedGroups = {}; - const valuesMapping = {}; + const valuesMapping = Object.create(null); preparedValues.forEach((d) => { valuesMapping[d] = true; @@ -1469,7 +1717,7 @@ export class VirtualSelect { } } else { disabledOptionsArr = disabledOptions.map((d) => d.toString()); - const disabledOptionsMapping = {}; + const disabledOptionsMapping = Object.create(null); disabledOptionsArr.forEach((d) => { disabledOptionsMapping[d] = true; @@ -1514,7 +1762,7 @@ export class VirtualSelect { return d; }); } else { - const enabledOptionsMapping = {}; + const enabledOptionsMapping = Object.create(null); enabledOptions.forEach((d) => { enabledOptionsMapping[d] = true; @@ -1546,7 +1794,7 @@ export class VirtualSelect { const getAlias = this.getAlias.bind(this); let index = 0; let hasOptionGroup = false; - const disabledOptionsMapping = {}; + const disabledOptionsMapping = Object.create(null); let hasEmptyValueOption = false; this.disabledOptions.forEach((d) => { @@ -1559,8 +1807,24 @@ export class VirtualSelect { d = { [valueKey]: d, [labelKey]: d }; } - const value = secureText(getString(d[valueKey])); - const label = secureText(getString(d[labelKey])); + /** + * `value` is stored verbatim; only `label` and `description` are escaped. + * + * Escaping is for HTML sinks, and the value has none: it goes into the `data-value` + * attribute (escaped there, at the boundary) and is otherwise only compared or used as a + * map key. Escaping it stored an identity the caller could not name - `a&b` became + * `a&b`, so setValue(['a&b']) matched nothing and a value read back could not be set + * again. + * + * The normalised search keys derive from the *raw* text for the same reason: they are + * matched against what the user types into the search box, which is never HTML-escaped. + * Deriving them from the escaped text meant no query could match text containing `&`, + * `<` or `>`. + */ + const rawValue = getString(d[valueKey]); + const rawLabel = getString(d[labelKey]); + const value = rawValue; + const label = secureText(rawLabel); const childOptions = d.options; const isGroupTitle = !!childOptions; const option = { @@ -1568,9 +1832,9 @@ export class VirtualSelect { value, valueNormalized: value.toLowerCase(), label, - labelNormalized: this.searchNormalize && label.trim() !== '' - ? Utils.normalizeString(label).toLowerCase() - : label.toLowerCase(), + labelNormalized: this.searchNormalize && rawLabel.trim() !== '' + ? Utils.normalizeString(rawLabel).toLowerCase() + : rawLabel.toLowerCase(), alias: getAlias(d[aliasKey]), isVisible: convertToBoolean(d.isVisible, true), isNew: d.isNew || false, @@ -1592,11 +1856,12 @@ export class VirtualSelect { } if (hasOptionDescription) { - const description = secureText(getString(d[descriptionKey])); - option.description = description; - option.descriptionNormalized = this.searchNormalize && description.trim() !== '' - ? Utils.normalizeString(description).toLowerCase() - : description.toLowerCase(); + const rawDescription = getString(d[descriptionKey]); + option.description = secureText(rawDescription); + /** normalised from the raw text, so a query containing `&` can match - see above */ + option.descriptionNormalized = this.searchNormalize && rawDescription.trim() !== '' + ? Utils.normalizeString(rawDescription).toLowerCase() + : rawDescription.toLowerCase(); } if (d.customData) { @@ -1647,7 +1912,7 @@ export class VirtualSelect { /** merging already selected options details with new options */ if (selectedOptions.length) { - const newOptionsValueMapping = {}; + const newOptionsValueMapping = Object.create(null); optionsUpdated = true; newOptions.forEach((d) => { @@ -1688,6 +1953,11 @@ export class VirtualSelect { } this.setVisibleOptionsCount(); DomUtils.removeClass(this.$allWrappers, 'server-searching'); + + /** replace the "loading" message with the outcome of the fetch */ + if (this.isInitialized) { + this.announce(this.getResultsCountMessage()); + } } setSelectedOptions() { @@ -1695,6 +1965,8 @@ export class VirtualSelect { } setSortedOptions() { + /** order drives aria-posinset */ + this.ariaMetadataDirty = true; let sortedOptions = [...this.options]; if (this.showSelectedOptionsFirst && this.selectedValues.length) { @@ -1795,8 +2067,27 @@ export class VirtualSelect { DomUtils.setAttr(this.$clearButton, 'tabindex', hasValue ? '0' : '-1'); DomUtils.setAria(this.$clearButton, 'hidden', hasValue === false); + let isValid = true; + if (!disableValidation) { - this.validate(); + isValid = this.validate(); + } + + /** + * Selection changes are otherwise conveyed only by the (visual) value text. + * Guarded on isInitialized so a value supplied at construction time is not + * announced before the user has interacted with anything. + * + * Skipped when validation just failed. validate() announces its message through the same + * polite region, and a polite region is read from its *final* content - so announcing the + * selection summary here overwrote the validation message in the same tick and the user + * never heard it. That silenced every interactive path (the clear button, deselecting below + * minValues) while still setting aria-invalid and showing the message on screen, which is + * the 3.3.1 failure this region exists to fix. The error is the more urgent of the two, and + * it already implies the selection state. + */ + if (this.isInitialized && isValid) { + this.announce(this.getSelectionMessage()); } if (!disableEvent) { @@ -1845,11 +2136,13 @@ export class VirtualSelect { const valueTooltipForTags = Utils.willTextOverflow($valueText.parentElement, label) ? this.getTooltipAttrText(label, false, true) : ''; - // replace is nedded to remove html tags from aria-label (ex: when there is an icon in the label) + /** markup in the label would otherwise land in the accessible name; a double + * quote in it would break out of the attribute entirely */ let ariaLabelClearBtnTxt = ''; if (this.ariaLabelTagClearButtonText) { - const stripHtmlLabel = label.replace(/<[^>]+>/ig, '').trim(); - ariaLabelClearBtnTxt = `aria-label="${stripHtmlLabel}, ${this.ariaLabelTagClearButtonText}"`; + const stripHtmlLabel = Utils.getAriaLabelText(label); + const clearButtonText = Utils.getAriaLabelText(this.ariaLabelTagClearButtonText); + ariaLabelClearBtnTxt = `aria-label="${stripHtmlLabel}, ${clearButtonText}"`; } const valueTagHtml = ` @@ -2020,6 +2313,14 @@ export class VirtualSelect { } this.visibleOptionsCount = visibleOptionsCount; + /** + * Number of options matching the current filter. Kept separately because + * setVisibleOptions() overwrites visibleOptionsCount with the size of the rendered + * virtualisation window, which is not what a "N results available" message means. + */ + this.filteredOptionsCount = visibleOptionsCount; + /** isVisible changed for the whole set, so positions and setsize must be recomputed */ + this.ariaMetadataDirty = true; this.afterSetVisibleOptionsCount(); } @@ -2123,12 +2424,16 @@ export class VirtualSelect { return; } + /** adds a row to the filtered set */ + this.ariaMetadataDirty = true; + const newOption = this.getNewOption(); if (newOption) { const newIndex = newOption.index; - this.setOptionProp(newIndex, 'value', this.secureText(value)); + /** value verbatim, label escaped - the label is the only one rendered as HTML */ + this.setOptionProp(newIndex, 'value', value); this.setOptionProp(newIndex, 'label', this.secureText(value)); } else { const data = { @@ -2148,7 +2453,7 @@ export class VirtualSelect { } setSelectedProp() { - const valuesMapping = {}; + const valuesMapping = Object.create(null); this.selectedValues.forEach((d) => { valuesMapping[d] = true; @@ -2168,7 +2473,7 @@ export class VirtualSelect { } const setNewOption = this.setNewOption.bind(this); - const availableValuesMapping = {}; + const availableValuesMapping = Object.create(null); this.options.forEach((d) => { availableValuesMapping[d.value] = true; @@ -2265,7 +2570,7 @@ export class VirtualSelect { return; } - const valuesMapping = {}; + const valuesMapping = Object.create(null); let selectedOptionIndex; selectedValues.forEach((d) => { @@ -2358,9 +2663,11 @@ export class VirtualSelect { } getTooltipAttrText(text, ellipsisOnly = false, allowHtml = false) { - const tootltipText = Utils.containsHTML(text) ? Utils.replaceDoubleQuotesWithHTML(text) : text; + /** quotes are escaped unconditionally by getAttributesText(); escaping again here would + * leave a literal " in the tooltip, and the old containsHTML() condition is what + * let a tag-free payload through in the first place */ const data = { - 'data-tooltip': tootltipText || '', + 'data-tooltip': text || '', 'data-tooltip-enter-delay': this.tooltipEnterDelay, 'data-tooltip-z-index': this.zIndex, 'data-tooltip-font-size': this.tooltipFontSize, @@ -2384,22 +2691,23 @@ export class VirtualSelect { const { getString } = Utils; const secureText = this.secureText.bind(this); - const value = secureText(getString(data.value)); - const label = secureText(getString(data.label)); - const description = secureText(getString(data.description)); + /** value stored verbatim, search keys derived from the raw text - see setOptions() */ + const rawValue = getString(data.value); + const rawLabel = getString(data.label); + const rawDescription = getString(data.description); return { index: data.index, - value, - valueNormalized: value.toLowerCase(), - label, - labelNormalized: this.searchNormalize && label.trim() !== '' - ? Utils.normalizeString(label).toLowerCase() - : label.toLowerCase(), - description, - descriptionNormalized: this.searchNormalize && description.trim() !== '' - ? Utils.normalizeString(description).toLowerCase() - : description.toLowerCase(), + value: rawValue, + valueNormalized: rawValue.toLowerCase(), + label: secureText(rawLabel), + labelNormalized: this.searchNormalize && rawLabel.trim() !== '' + ? Utils.normalizeString(rawLabel).toLowerCase() + : rawLabel.toLowerCase(), + description: secureText(rawDescription), + descriptionNormalized: this.searchNormalize && rawDescription.trim() !== '' + ? Utils.normalizeString(rawDescription).toLowerCase() + : rawDescription.toLowerCase(), alias: this.getAlias(data.alias), isCurrentNew: data.isCurrentNew || false, isNew: data.isNew || false, @@ -2434,7 +2742,7 @@ export class VirtualSelect { } getNewValue() { - const valuesMapping = {}; + const valuesMapping = Object.create(null); this.newValues.forEach((d) => { valuesMapping[d] = true; @@ -2501,7 +2809,7 @@ export class VirtualSelect { }); if (keepSelectionOrder) { - const valuesOrder = {}; + const valuesOrder = Object.create(null); selectedValues.forEach((d, i) => { valuesOrder[d] = i; @@ -2515,7 +2823,7 @@ export class VirtualSelect { getDisabledOptions() { const { valueKey, labelKey, disabledOptions } = this; - const disabledOptionsValueMapping = {}; + const disabledOptionsValueMapping = Object.create(null); const result = []; disabledOptions.forEach((value) => { @@ -2671,6 +2979,13 @@ export class VirtualSelect { DomUtils.setStyle(this.$dropboxContainer, 'display', 'inline-flex'); } else { DomUtils.dispatchEvent(this.$ele, 'beforeOpen'); + /** + * The wrapper is the one combobox and the one carrier of aria-expanded. The search + * input deliberately is not a second combobox: nesting one combobox inside another is + * a structure screen readers disagree on, and aria-expanded is not a supported state + * of the input's implicit textbox role - which does support the wiring the input + * needs (aria-autocomplete, aria-controls, aria-activedescendant). + */ DomUtils.setAria(this.$wrapper, 'expanded', true); } @@ -2749,9 +3064,21 @@ export class VirtualSelect { } else { DomUtils.dispatchEvent(this.$ele, 'beforeClose'); DomUtils.setAria(this.$wrapper, 'expanded', false); - DomUtils.setAria(this.$wrapper, 'activedescendant', ''); - // Also clear aria-activedescendant on the listbox container - DomUtils.setAria(this.$dropboxContainer, 'activedescendant', ''); + /** + * No option is active once the list is gone - and the highlight has to go with it, + * here, synchronously. + * + * afterHidePopper() already calls removeOptionFocus(), but for popover-backed + * instances it only runs when the hide transition ends (~200ms later). Until then the + * previous highlight and `focusedOptionIndex` survived the close, so reopening within + * that window resumed navigation from the old position instead of the first option: + * the next Up/Down moved one step past where the user expected, which on a grouped + * multi-select meant Enter landed on the first child option instead of toggling the + * group title. removeOptionFocus() is a no-op when nothing is highlighted, so leaving + * the afterHidePopper() call in place costs nothing and still covers the silent path. + */ + this.removeOptionFocus(); + this.setActiveDescendant(''); } if (this.dropboxPopover && !isSilent) { @@ -2769,7 +3096,26 @@ export class VirtualSelect { this.afterHidePopper(); } - this.setSearchValue(''); + /** + * Clearing the filter runs afterSetSearchValue(), which highlights the first visible + * option again. That undid the removeOptionFocus() above whenever the user had typed + * something: the highlight and aria-activedescendant came straight back on a combobox + * already marked aria-expanded="false", and focusOption() pulled DOM focus onto an option + * that is about to be display:none - so the keyboard position ended up on . + * + * isClosing is scoped to this one call rather than the whole method because everything + * above it (the wrapper refocus in particular) still needs the real state. The reset runs + * in a finally: if setSearchValue() ever threw, a stuck flag would silently stop the + * highlight coming back after *every* later filter clear, which is far harder to diagnose + * than the exception itself. + */ + this.isClosing = true; + + try { + this.setSearchValue(''); + } finally { + this.isClosing = false; + } } afterHidePopper() { @@ -3185,7 +3531,15 @@ export class VirtualSelect { isAllVisibleSelected = this.isAllOptionsSelected(true); } - DomUtils.toggleClass(this.$toggleAllCheckbox, 'checked', isAllSelected || isAllVisibleSelected); + const isChecked = isAllSelected || isAllVisibleSelected; + + DomUtils.toggleClass(this.$toggleAllCheckbox, 'checked', isChecked); + /** + * Mirror the visual checked state onto the role="checkbox" host. This is the single + * point every selection path funnels through (select all, deselect all, per-option + * clicks, group toggles, setValue, reset), so the exposed state cannot drift. + */ + DomUtils.setAria(this.$toggleAllButton, 'checked', isChecked); this.isAllSelected = isAllSelected; } @@ -3249,7 +3603,7 @@ export class VirtualSelect { const groupIndex = DomUtils.getData($ele, 'index', 'number'); const { selectedValues, selectAllOnlyVisible } = this; - const valuesMapping = {}; + const valuesMapping = Object.create(null); const { removeItemFromArray } = Utils; selectedValues.forEach((d) => { @@ -3301,7 +3655,15 @@ export class VirtualSelect { } toggleFocusedProp(index, isFocused = false) { - if (this.focusedOptionIndex) { + /** + * Explicitly against null, not truthiness. focusedOptionIndex comes from + * DomUtils.getData($ele, 'index') with no type, so today it is the *string* "0" and a + * truthiness test happens to pass for the first option. Normalise it to a number anywhere + * and index 0 would stop being cleared, so its `isFocused` prop would survive - and + * renderOptions() re-applies `.focused` and tabindex="0" from that prop, bringing the + * stale highlight back through the data path on the next render. + */ + if (this.focusedOptionIndex !== null && this.focusedOptionIndex !== undefined) { this.setOptionProp(this.focusedOptionIndex, 'isFocused', false); } @@ -3332,7 +3694,18 @@ export class VirtualSelect { this.afterValueSet(); if (formReset) { + /** + * A native form reset clears the error state, not just the colour that showed it. + * + * Removing `has-error` alone left aria-invalid="true" on the combobox and + * aria-describedby pointing at an error element that still held its text - so the control + * stayed announced as invalid, describing a message the user could no longer see, with no + * interaction able to clear it. setErrorMessage('') empties the text and drops + * aria-describedby, and does not announce (it only announces a non-empty message). + */ DomUtils.removeClass(this.$allWrappers, 'has-error'); + DomUtils.toggleAria(this.$allWrappers, 'invalid', false); + this.setErrorMessage(''); } DomUtils.dispatchEvent(this.$ele, 'reset'); @@ -3372,6 +3745,8 @@ export class VirtualSelect { const newOption = this.getNewOption(); if (newOption) { + /** removes a row from the filtered set */ + this.ariaMetadataDirty = true; this.removeOption(newOption.index); } } @@ -3510,6 +3885,9 @@ 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); + this.setSelectedOptions(); this.onServerSearch(this.searchValue, this); } @@ -3551,22 +3929,69 @@ export class VirtualSelect { } let hasError = false; + let errorText = ''; const { selectedValues, minValues } = this; - if ( - this.required && - (Utils.isEmpty(selectedValues) || + if (this.required) { + if (Utils.isEmpty(selectedValues)) { + hasError = true; + errorText = this.requiredErrorText; + } else if (this.multiple && minValues && selectedValues.length < minValues) { /** required minium options not selected */ - (this.multiple && minValues && selectedValues.length < minValues)) - ) { - hasError = true; + hasError = true; + errorText = Utils.getString(this.minValuesErrorText).replace('{count}', minValues); + } } DomUtils.toggleClass(this.$allWrappers, 'has-error', hasError); + /** + * Previously the only signal was the `has-error` class recolouring the toggle button + * border: invisible to assistive technology and, being colour alone, a 1.4.1 failure. + * Expose the state (aria-invalid), give it a text message, point the combobox at that + * message (aria-describedby) and announce it. + */ + DomUtils.toggleAria(this.$allWrappers, 'invalid', hasError); + this.setErrorMessage(hasError ? errorText : ''); + return !hasError; } + /** + * Show or clear the validation message and its association with the combobox. + * An empty message removes aria-describedby rather than pointing at empty text. + * + * @param {string} message + */ + setErrorMessage(message) { + if (!this.$errorMessage) { + return; + } + + const text = message || ''; + + this.$errorMessage.textContent = text; + DomUtils.toggleAria(this.$allWrappers, 'describedby', !!text, this.$errorMessage.id); + + /** + * The message is shown and exposed unconditionally, but only *announced* for something the + * user did. A live region is for status changes they caused. + * + * isInitialized keeps construction quiet: the initial setValueMethod() runs before that flag + * is set, so a page supplied with an invalid initial value used to load already speaking + * "Select at least 2 options". isRefreshingOptions keeps a programmatic data swap quiet: + * afterSetOptions() calls reset(), which validates, so replacing the options announced a + * failure for a field the user had never touched. + * + * Both are deliberately narrow. The interactive paths - the clear button, deselecting below + * minValues, and an explicit validate() from the application - must still announce, which is + * the whole point of routing validation through this region. + */ + if (text && this.isInitialized && !this.isRefreshingOptions) { + this.announce(text); + } + } + /** * setTimeout wrapper whose pending timers are tracked so they can be cleared on destroy(). * Prevents callbacks from running against a destroyed instance (stale DOM access / retention). @@ -3621,6 +4046,12 @@ export class VirtualSelect { // Clear any other pending timeouts so their callbacks don't run on a destroyed instance this.clearManagedTimeouts(); + // Drop any queued scroll re-render so it cannot touch detached DOM + if (this.scrollAnimationFrame) { + cancelAnimationFrame(this.scrollAnimationFrame); + this.scrollAnimationFrame = null; + } + /** Remove all event listeners to prevent memory leaks and ensure proper cleanup */ this.removeEvents(); @@ -3655,8 +4086,20 @@ export class VirtualSelect { if (!text || !this.enableSecureText) { return text; } - /** escape potentially harmful JavaScript so, label and value fields cannot trigger XSS */ - this.$secureText.nodeValue = Utils.replaceDoubleQuotesWithHTML(text); + + /** + * escape potentially harmful markup so label/value/description cannot trigger XSS. + * + * Quotes are deliberately *not* rewritten here. They were, and the text node's innerHTML + * then escaped the `&` that introduced - so `The "City" of Light` used to be stored as + * `The &quot;City&quot; of Light`, shown to the user as `The "City" of + * Light`, and made unsearchable, because labelNormalized derives from the stored text. + * Quotes only need escaping inside an attribute, and that now happens at each attribute + * boundary instead (data-value in renderOptions, DomUtils.getAttributesText) - which also + * covers the sinks this pre-escaping never reached, such as an attribute written while + * enableSecureText is off. + */ + this.$secureText.nodeValue = text; return this.$secureDiv.innerHTML; } @@ -3686,9 +4129,94 @@ export class VirtualSelect { ); } + /** + * Write a message into the instance's polite live region (WCAG 4.1.3 Status Messages). + * + * Identical consecutive messages are intentionally left alone: re-writing the same text + * produces no DOM mutation, so assistive technology does not repeat "No results found" + * on every further keystroke that still matches nothing. + * + * @param {string} message + */ + announce(message) { + if (!this.$liveRegion) { + return; + } + + /** + * Reduced to plain text because the region is written with textContent, so whatever is put + * there is read out literally. A single select announces the chosen label, and a label can + * carry both escaping and markup: with enableSecureText on the region said + * "Tom & Jerry selected", and decoding alone would only have turned that into + * " France selected". Neither is speech. Messages the component composes + * itself contain no markup, so this is a no-op for them. + */ + const text = Utils.getPlainText(message || ''); + + if (this.$liveRegion.textContent !== text) { + this.$liveRegion.textContent = text; + } + } + + /** + * Message describing how many options the current filter matched. + * @returns {string} + */ + getResultsCountMessage() { + const count = this.filteredOptionsCount || 0; + + if (count === 0) { + return this.noSearchResultsText; + } + + return `${count} ${count === 1 ? this.searchResultText : this.searchResultsText}`; + } + + /** + * Message describing the current selection. + * @returns {string} + */ + getSelectionMessage() { + const count = this.selectedValues.length; + + if (count === 0) { + return this.noOptionsSelectedText; + } + + if (this.multiple) { + return `${count} ${count === 1 ? this.optionSelectedText : this.optionsSelectedText}`; + } + + /** option flags are updated before setValue(), so the label is already current */ + const label = this.getDisplayValue() || this.selectedValues[0]; + + return `${label} ${this.selectedText}`; + } + + /** + * Announce the match count, but only while the user is actually searching. + * setSearchValue('') also runs on close and after a value is set; announcing there + * would read a stale count into the user's ear for an interaction they did not make. + */ + announceSearchResults() { + if (!this.isInitialized || !this.isOpened() || document.activeElement !== this.$searchInput) { + return; + } + + this.announce(this.getResultsCountMessage()); + } + toggleRequired(isRequired) { this.required = Utils.convertToBoolean(isRequired); this.$ele.required = this.required; + DomUtils.toggleAria(this.$allWrappers, 'required', this.required); + + /** dropping the requirement also drops any error it produced */ + if (!this.required) { + DomUtils.toggleClass(this.$allWrappers, 'has-error', false); + DomUtils.toggleAria(this.$allWrappers, 'invalid', false); + this.setErrorMessage(''); + } } toggleOptionSelectedState($ele, value) { @@ -3710,18 +4238,93 @@ export class VirtualSelect { DomUtils.toggleClass($ele, 'focused', isFocused); DomUtils.setAttr($ele, 'tabindex', isFocused ? '0' : '-1'); - if (document.activeElement !== this.$searchInput) { + /** + * Only *taking* the highlight moves DOM focus. Clearing it used to focus the element it + * had just un-highlighted, which is either pointless (focusOption immediately focuses the + * new option anyway) or actively wrong: on close it pulled focus into a dropbox that is + * about to be hidden, fighting the wrapper refocus in closeDropbox(). + */ + if (isFocused && document.activeElement !== this.$searchInput) { $ele.focus(); } - if (isFocused) { - DomUtils.setAria(this.$wrapper, 'activedescendant', $ele.id); - // Also set aria-activedescendant on the listbox container for better screen reader support - DomUtils.setAria(this.$dropboxContainer, 'activedescendant', $ele.id); - } + /** + * Publish the highlight on the elements that can carry it: the wrapper (the combobox) + * and the search input (a textbox, which also supports aria-activedescendant). It used + * to also go on $dropboxContainer, a plain div with no role, where aria-activedescendant + * is meaningless - and never on the search input, which is the element that actually + * holds focus while navigating. + */ + this.setActiveDescendant(isFocused ? $ele.id : ''); + } + + /** + * Point the combobox wrapper and the search input at the active option, or clear the + * reference. + * @param {string} optionId + */ + setActiveDescendant(optionId) { + DomUtils.toggleAria(this.$wrapper, 'activedescendant', !!optionId, optionId); + DomUtils.toggleAria(this.$searchInput, 'activedescendant', !!optionId, optionId); } /** static methods - start */ + + /** + * Set page-level default props applied to every instance created afterwards. + * + * The motivating case is security: option text is interpolated into innerHTML and is only + * escaped when `enableSecureText` is on, which it is not by default (escaping costs per + * option, and large trusted lists should not pay for it). A host that does render + * untrusted option text can turn escaping on once here rather than at every call site: + * + * VirtualSelect.setGlobalDefaults({ enableSecureText: true }); + * + * These are defaults, not overrides: an instance passing the prop explicitly still wins, + * so a host forwarding `enableSecureText` on every init must stop doing so (or forward + * `true`) for this to take effect. Calls merge, so features can be configured separately. + * Only instances created after the call are affected. + * + * @param {Partial} props + */ + static setGlobalDefaults(props) { + /** + * A non-object is ignored, not treated as "clear": a host forwarding an accidentally + * unset config variable would otherwise silently turn a page-wide security policy off. + * Clearing is an explicit act - resetGlobalDefaults(). A key can still be cleared + * individually by passing it with the value `undefined`, which setDefaultProps() + * treats as "not supplied". + */ + if (!props || typeof props !== 'object') { + return; + } + + /** `ele` and `options` are per-instance by nature and would alias state across instances */ + const safeProps = { ...props }; + delete safeProps.ele; + delete safeProps.options; + + VirtualSelect.globalDefaults = { ...VirtualSelect.globalDefaults, ...safeProps }; + } + + /** + * Drop every page-level default, restoring the built-in ones for instances created + * afterwards. The explicit counterpart to setGlobalDefaults(), which only ever merges. + */ + static resetGlobalDefaults() { + VirtualSelect.globalDefaults = {}; + } + + /** + * Currently active page-level defaults. + * A copy, so callers cannot mutate the live object. + * + * @returns {Partial} + */ + static getGlobalDefaults() { + return { ...VirtualSelect.globalDefaults }; + } + static init(options) { let $eleArray = options.ele; @@ -4003,6 +4606,9 @@ VirtualSelect.lastInteractedInstance = null; // Ensures the "enableSecureText disabled" warning is logged at most once per page VirtualSelect.secureTextWarningShown = false; +// Page-level default props, applied under per-instance options (see setGlobalDefaults) +VirtualSelect.globalDefaults = {}; + /** polyfill to fix an issue in ie browser */ if (typeof NodeList !== 'undefined' && NodeList.prototype && !NodeList.prototype.forEach) { NodeList.prototype.forEach = Array.prototype.forEach; diff --git a/src/virtual-select.types.js b/src/virtual-select.types.js index 42657d2a..d5e769f7 100644 --- a/src/virtual-select.types.js +++ b/src/virtual-select.types.js @@ -98,6 +98,19 @@ * and only one value is selected (i.e. 1 option selected) * @property {string} [allOptionsSelectedText=All] Text to use when displaying all values selected text * (i.e. All (10)) + * @property {string} [searchResultsText='results available'] Announced in the live region after a search + * (i.e. 5 results available) + * @property {string} [searchResultText='result available'] Singular form of searchResultsText + * (i.e. 1 result available) + * @property {string} [noOptionsSelectedText='No options selected'] Announced in the live region when the + * selection becomes empty + * @property {string} [selectedText=selected] Announced in the live region after a single select choice + * (i.e. Option 3 selected) + * @property {string} [loadingText='Loading results'] Announced in the live region while a server search is in flight + * @property {string} [requiredErrorText='This field is required'] Validation message shown and announced when a + * required select has no value + * @property {string} [minValuesErrorText='Select at least {count} options'] Validation message when fewer than + * minValues options are selected; {count} is replaced with minValues * @property {string} [clearButtonText=Clear] Tooltip text for clear button * @property {string} [moreText='more...'] Text to show when more than noOfDisplayValues options selected * (i.e + 10 more...)