Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Contributing

## Tests

Each package is tested with the Node built-in test runner plus Playwright:

```
npm test # all packages, via lerna
npm run lint # oxlint across the repo
```
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@ Each package is available for use on npm. See [the docs](https://github.com/stor
| Package | Description |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Autocomplete](./packages/autocomplete) | Accessible autocomplete / combobox (single, multiple, async and `<select>` enhancement) |
| [Boilerplate](./packages/boilerplate) | Boilerplate for developing new components |
| [Cookie Banner](./packages/cookie-banner) | GDPR compliant cookie banner |
| [Modal](./packages/modal) | Accessible modal dialog |
| [Modal Gallery](./packages/modal-gallery) | Accessible modal gallery |
| [Outliner](./packages/outliner) | Hide CSS outline on mouse interactions, until [:focus-visible](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) has broader support |
| [Outliner](./packages/outliner) | Legacy-browser fallback for hiding focus outlines on mouse interactions — prefer native [:focus-visible](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) |
| [Scroll Points](./packages/scroll-points) | Trigger events based on element intersecting the viewport |
| [Scroll Spy](./packages/scroll-spy) | Update elements based on target element intersecting the viewport |
| [Skip](./packages/skip) | Ensure fragment identifier links focus on their target node |
| [Skip](./packages/skip) | Move focus to fragment identifier link targets — for cases native focus and a static [tabindex="-1"](./packages/skip) don't cover |
| [Tabs](./packages/tabs) | Accessible tabbed panelled content areas |
| [Textarea](./packages/textarea) | Auto-resizing textarea |
| [Textarea](./packages/textarea) | Auto-resizing textarea — fallback for browsers without native [field-sizing: content](https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing) |
| [Toggle](./packages/toggle) | Accessible DOM element expand and collapse |
| [Validate](./packages/validate) | Client-side form validation library |

Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
],
"command": {
"publish": {
"ignoreChanges": ["*.md", "/packages/boilerplate"],
"ignoreChanges": ["*.md"],
"message": "Publish"
},
"version": {
Expand Down
8 changes: 4 additions & 4 deletions packages/autocomplete/__tests__/unit/escape.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const search = query => values.filter(item => item.value.includes(query));

const init = (options = {}) => mount(host('f', 'Fruit'), { name: 'f', minlength: 1, search, ...options });

const key = (target, keyCode) => target.dispatchEvent(new KeyboardEvent('keydown', { keyCode, bubbles: true }));
const key = (target, k) => target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));

describe('Autocomplete > Escape', () => {
beforeEach(() => {
Expand All @@ -24,7 +24,7 @@ describe('Autocomplete > Escape', () => {
const input = node.querySelector('input');
input.focus();
type(input, 'ap'); // list opens, focus stays on the input (nothing arrowed to)
key(input, 27);
key(input, 'Escape');

//aria-selected is invalid on role="combobox" and must never be stamped there
assert.strictEqual(input.hasAttribute('aria-selected'), false);
Expand All @@ -36,15 +36,15 @@ describe('Autocomplete > Escape', () => {
const input = node.querySelector('input');
input.focus();
type(input, 'ap');
key(input, 40); // ArrowDown highlights the first option; focus stays in the input
key(input, 'ArrowDown'); // ArrowDown highlights the first option; focus stays in the input

const option = node.querySelector('[role="option"]');
assert.strictEqual(document.activeElement, input);
assert.strictEqual(option.getAttribute('aria-selected'), 'true');
assert.strictEqual(option.classList.contains('autocomplete__option--active'), true);
assert.strictEqual(input.getAttribute('aria-activedescendant'), option.id);

key(input, 27);
key(input, 'Escape');
assert.strictEqual(option.getAttribute('aria-selected'), 'false');
assert.strictEqual(option.classList.contains('autocomplete__option--active'), false);
assert.strictEqual(document.activeElement, input);
Expand Down
4 changes: 2 additions & 2 deletions packages/autocomplete/__tests__/unit/events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const search = query => values.filter(item => item.value.toLowerCase().includes(

const init = (options = {}) => mount(host(), { name: 'fruit', minlength: 1, search, ...options });

const escape = input => input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27, bubbles: true }));
const escape = input => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));

//capture several event types in one ordered list, so the sequence a consumer sees
//can be asserted rather than each type in isolation
Expand Down Expand Up @@ -77,7 +77,7 @@ describe('Autocomplete > Events', () => {
type(input, 'ap');
clickOption(node, 0);
input.value = '';
input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 8, bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));

assert.strictEqual(events.length, 1);
assert.deepStrictEqual(events[0].detail.option, { value: 'Apple', label: 'Apple' });
Expand Down
16 changes: 16 additions & 0 deletions packages/autocomplete/__tests__/unit/init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,28 @@ describe('Autocomplete > Initialisation', () => {
assert.notStrictEqual(instance.node, null);
assert.strictEqual(typeof instance.getState, 'function');
assert.strictEqual(typeof instance.clear, 'function');
assert.strictEqual(typeof instance.destroy, 'function');
});

it('should return an empty array (not undefined) when no nodes match', () => {
assert.deepStrictEqual(autocomplete('.js-not-here'), []);
});

it('should stop responding to input once destroyed (listeners removed)', async () => {
const [instance] = autocomplete('.js-autocomplete', { values });
const input = instance.node.querySelector('input');

instance.destroy();

//the input listener is gone, so typing must not open the listbox
input.value = 'Ap';
input.dispatchEvent(new Event('input', { bubbles: true }));
//past the 200ms debounce, plus a tick for any resolved promise
await new Promise(resolve => setTimeout(resolve, 250));

assert.strictEqual(instance.getState().open, false);
});

it('should let data attributes override options', () => {
document.body.innerHTML = '<div class="js-autocomplete" data-minlength="5"></div>';
const [instance] = autocomplete('.js-autocomplete', { minlength: 3, values });
Expand Down
4 changes: 2 additions & 2 deletions packages/autocomplete/__tests__/unit/multiple.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe('Autocomplete > Multiple', () => {
type(input, 'ap');
clickOption(node, 0);
input.value = '';
input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 8, bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));

assert.deepStrictEqual(instance.getState().selected, []);
});
Expand All @@ -186,7 +186,7 @@ describe('Autocomplete > Multiple', () => {
type(input, 'ap');
clickOption(node, 0);
input.value = 'a';
input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 8, bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));

assert.strictEqual(instance.getState().selected.length, 1);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/autocomplete/__tests__/unit/status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('Autocomplete > Status', () => {
type(input, 'apple');
clickOption(node, 0);
input.value = '';
input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 8, bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));

assert.strictEqual(node.querySelector('.autocomplete__status').textContent, 'Apple removed');
});
Expand Down
12 changes: 6 additions & 6 deletions packages/autocomplete/__tests__/unit/submit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ const initInForm = (options = {}) => {
return { instance, node: instance.node, submits };
};

const enter = target => target.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, bubbles: true }));
const enter = target => target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
//highlight the next option (activedescendant model: focus stays in the input, so the
//arrow fires on the input rather than moving focus onto an <li>)
const arrowDown = input => input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 40, bubbles: true }));
const arrowDown = input => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));

describe('Autocomplete > submitOnConfirm', () => {
beforeEach(() => {
Expand Down Expand Up @@ -71,7 +71,7 @@ describe('Autocomplete > submitOnConfirm', () => {
arrowDown(input); // highlight the first option
//Tab commits the highlighted option via confirmOnBlur, but tabbing past the
//field must not navigate
input.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9, bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));

assert.strictEqual(instance.getState().selected.value, 'Apple');
assert.strictEqual(submits.length, 0);
Expand Down Expand Up @@ -109,7 +109,7 @@ describe('Autocomplete > Enter (submitOnConfirm off)', () => {
const instance = init();
const input = instance.node.querySelector('input');
type(input, 'ap'); // list open with matches, but no option highlighted
const event = new KeyboardEvent('keydown', { keyCode: 13, bubbles: true, cancelable: true });
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);

assert.strictEqual(event.defaultPrevented, false); // native submit not blocked
Expand All @@ -122,7 +122,7 @@ describe('Autocomplete > Enter (submitOnConfirm off)', () => {
const input = instance.node.querySelector('input');
type(input, 'ap');
arrowDown(input); // highlight the first option
const event = new KeyboardEvent('keydown', { keyCode: 13, bubbles: true, cancelable: true });
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);

assert.strictEqual(event.defaultPrevented, true);
Expand All @@ -135,7 +135,7 @@ describe('Autocomplete > Enter (submitOnConfirm off)', () => {
type(input, 'ap');
clickOption(node, 0); // commit Apple; focus returns to the input

const event = new KeyboardEvent('keydown', { keyCode: 13, bubbles: true, cancelable: true });
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);

assert.strictEqual(event.defaultPrevented, false); // native submit not blocked
Expand Down
13 changes: 1 addition & 12 deletions packages/autocomplete/__tests__/unit/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { capResults, debounce, filterOptions, fromSelect, areEqual, isPrintableKeyCode, uid, fromValues, toValueArray, resolveMsg, escapeHtml, html, isHtml } from '../../src/lib/utils.js';
import { capResults, debounce, filterOptions, fromSelect, areEqual, uid, fromValues, toValueArray, resolveMsg, escapeHtml, html, isHtml } from '../../src/lib/utils.js';

const makeSelect = html => {
const select = document.createElement('select');
Expand Down Expand Up @@ -154,17 +154,6 @@ describe('Autocomplete > Utils > areEqual', () => {
});
});

describe('Autocomplete > Utils > isPrintableKeyCode', () => {

it('should be true for letters, numbers, space and backspace', () => {
[65, 48, 32, 8].forEach(code => assert.strictEqual(isPrintableKeyCode(code), true));
});

it('should be false for navigation and control keys', () => {
[9, 13, 27, 38, 40].forEach(code => assert.strictEqual(isPrintableKeyCode(code), false));
});
});

describe('Autocomplete > Utils > uid', () => {

it('should return prefixed, unique, incrementing ids', () => {
Expand Down
15 changes: 1 addition & 14 deletions packages/autocomplete/playwright.config.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@

const { defineConfig } = require('@playwright/test');
const baseConfig = require('../../tools/playwright/config.base.js');
const server = require('./tools/playwright.rspack.config.js');

module.exports = defineConfig({
...baseConfig,
use: {
...baseConfig.use,
baseURL: `http://localhost:${server.devServer.port}/`,
},
webServer: {
...baseConfig.webServer,
url: `http://localhost:${server.devServer.port}/`,
},
});
module.exports = defineConfig(require('../../tools/playwright/config.base.js'));
9 changes: 5 additions & 4 deletions packages/autocomplete/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import defaults from './lib/defaults.js';
import factory from './lib/factory.js';
import { getSelection } from './lib/utils.js';
import { getSelection, coerceSettings, BOOLEAN_SETTINGS } from './lib/utils.js';

//re-exported for building an optionTemplate that renders markup (see renderOptions):
//`html` is a tagged template that escapes its interpolations automatically, and is the
Expand All @@ -26,10 +26,11 @@ export default (selector, options) => {
}

//return array of Objects, one for each DOM node found
//each Object has a prototype consisting of the node (HTMLElement),
//and a settings property composed from defaults, data-attributes on the node, and options passed to init
//each Object has a prototype consisting of the node (HTMLElement), and a settings property composed
//from defaults, options passed to init, and data-attributes on the node (coerced to their intended
//types). data-attributes are applied last so a single init call can be tuned per node
return nodes.map(node => Object.create(factory({
settings: { ...defaults, ...options, ...node.dataset },
settings: coerceSettings({ ...defaults, ...options, ...node.dataset }, { booleans: BOOLEAN_SETTINGS }),
node
})));
};
16 changes: 9 additions & 7 deletions packages/autocomplete/src/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ export const EVENTS = {
close: 'autocomplete:close'
};

export const KEYCODES = {
8: 'backspace',
9: 'tab',
13: 'enter',
27: 'escape',
38: 'up',
40: 'down'
// Keyboard keys (KeyboardEvent.key values) the combobox responds to.
// KeyboardEvent.keyCode is deprecated - match on the standard event.key string instead.
export const KEYS = {
BACKSPACE: 'Backspace',
TAB: 'Tab',
ENTER: 'Enter',
ESC: 'Escape',
UP: 'ArrowUp',
DOWN: 'ArrowDown'
};
23 changes: 17 additions & 6 deletions packages/autocomplete/src/lib/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,15 @@ export const broadcast = (store, action, option = null) => {
//enhancing a server-rendered input keeps whatever it was authored with.
const OPTIONAL_INPUT_ATTRIBUTES = [ 'inputmode', 'autocorrect', 'autocapitalize' ];

//a data-* attribute arrives as a string, so "false" must not be read as the truthy
//string it technically is
const isFalse = value => value === false || value === 'false';

const applyInputAttributes = (input, settings) => {
OPTIONAL_INPUT_ATTRIBUTES.forEach(attribute => {
const value = settings[attribute];
if (value !== null && value !== undefined && value !== '') input.setAttribute(attribute, value);
});
//always written (defaulting to off — see the README on why), and enumerated rather
//than boolean, so it has to carry the string "false" to switch checking off
input.setAttribute('spellcheck', String(!isFalse(settings.spellcheck)));
//than boolean, so it has to carry the string "false" to switch checking off. settings.spellcheck
//is coerced to a real Boolean at init, so String() yields the "true"/"false" the attribute needs
input.setAttribute('spellcheck', String(settings.spellcheck));
};

export const createInput = ({ node, settings, id, listId, describedby, input }) => {
Expand Down Expand Up @@ -152,6 +149,20 @@ export const setupListeners = state => {
if (state.dom.input.form) state.dom.input.form.addEventListener('reset', state.handle.form.reset);
};

//Mirror of setupListeners: removes every listener it added, using the same stable handler
//references held on state.handle. The enclosing form's reset listener is the one that outlives the
//node (the rest go with the enhanced markup if it's removed), so detaching it matters most.
export const teardownListeners = state => {
state.dom.input.removeEventListener('input', state.handle.input.input);
state.dom.input.removeEventListener('focus', state.handle.input.focus);
state.dom.input.removeEventListener('blur', state.handle.input.blur);
state.dom.list.removeEventListener('click', state.handle.option.click);
state.dom.node.removeEventListener('keydown', state.handle.container.keydown);
state.dom.list.removeEventListener('mousedown', state.handle.option.mousedown);
if (state.dom.output) state.dom.output.removeEventListener('click', state.handle.chip.remove);
if (state.dom.input.form) state.dom.input.form.removeEventListener('reset', state.handle.form.reset);
};

//emptying the list also drops the input's pointer to the (now gone) active option
export const emptyList = state => {
state.dom.list.replaceChildren();
Expand Down
7 changes: 6 additions & 1 deletion packages/autocomplete/src/lib/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createHint,
createSelectionSummary,
setupListeners,
teardownListeners,
setValue,
syncOutput,
createHiddenValue
Expand Down Expand Up @@ -163,6 +164,10 @@ export default ({ node, settings }) => {
return {
node,
getState: store.getState,
clear: clear(store)
clear: clear(store),
//remove every listener this instance added (see teardownListeners). The decorated DOM is left
//in place, matching the other components' destroy() — the original <select> was consumed at
//enhancement, so it can't be restored anyway.
destroy: () => teardownListeners(store.getState())
};
};
Loading
Loading