@vakwerk/navigation 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -2
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +33 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +324 -0
- package/dist/controller.js.map +1 -0
- package/dist/dom.d.ts +7 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +35 -0
- package/dist/dom.js.map +1 -0
- package/dist/element.d.ts +10 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +38 -0
- package/dist/element.js.map +1 -0
- package/dist/navigation.d.ts +32 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +50 -0
- package/dist/navigation.js.map +1 -0
- package/dist/overlay.d.ts +19 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +75 -0
- package/dist/overlay.js.map +1 -0
- package/dist/submenus.d.ts +48 -0
- package/dist/submenus.d.ts.map +1 -0
- package/dist/submenus.js +209 -0
- package/dist/submenus.js.map +1 -0
- package/package.json +28 -3
- package/src/navigation.css +461 -0
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { uniqueId } from './dom.js';
|
|
2
|
+
/**
|
|
3
|
+
* The menu button and the `<dialog>` panel of one navigation. Both exist while the navigation is
|
|
4
|
+
* enhanced; the navigation's own `<ul>` moves into the panel in overlay mode and back to its place
|
|
5
|
+
* in `<nav>` in inline mode, so there is only ever one list.
|
|
6
|
+
*/
|
|
7
|
+
export class Overlay {
|
|
8
|
+
button;
|
|
9
|
+
dialog;
|
|
10
|
+
closeButton;
|
|
11
|
+
#nav;
|
|
12
|
+
#list;
|
|
13
|
+
#inner;
|
|
14
|
+
/** The node the list sat before in `<nav>`, to put it back in the same place. */
|
|
15
|
+
#listNext;
|
|
16
|
+
constructor(nav, list) {
|
|
17
|
+
const doc = nav.ownerDocument;
|
|
18
|
+
this.#nav = nav;
|
|
19
|
+
this.#list = list;
|
|
20
|
+
this.#listNext = list.nextSibling;
|
|
21
|
+
this.dialog = doc.createElement('dialog');
|
|
22
|
+
this.dialog.className = 'ui-nav__panel';
|
|
23
|
+
this.dialog.id = uniqueId(doc, 'ui-nav-');
|
|
24
|
+
// The dialog takes the navigation's name, whichever way the nav is named.
|
|
25
|
+
for (const attribute of ['aria-labelledby', 'aria-label']) {
|
|
26
|
+
const value = nav.getAttribute(attribute);
|
|
27
|
+
if (value) {
|
|
28
|
+
this.dialog.setAttribute(attribute, value);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
this.#inner = doc.createElement('div');
|
|
33
|
+
this.#inner.className = 'ui-nav__panel-inner';
|
|
34
|
+
this.closeButton = doc.createElement('button');
|
|
35
|
+
this.closeButton.type = 'button';
|
|
36
|
+
this.closeButton.className = 'ui-nav__close';
|
|
37
|
+
// No autofocus: as the first focusable element in the dialog, showModal() focuses it anyway,
|
|
38
|
+
// and an autofocus element inserted while something else has focus makes Chrome log a message.
|
|
39
|
+
this.#inner.append(this.closeButton);
|
|
40
|
+
this.dialog.append(this.#inner);
|
|
41
|
+
this.button = doc.createElement('button');
|
|
42
|
+
this.button.type = 'button';
|
|
43
|
+
this.button.className = 'ui-nav__menu-button';
|
|
44
|
+
this.button.setAttribute('aria-expanded', 'false');
|
|
45
|
+
this.button.setAttribute('aria-haspopup', 'dialog');
|
|
46
|
+
this.button.setAttribute('aria-controls', this.dialog.id);
|
|
47
|
+
nav.prepend(this.button, this.dialog);
|
|
48
|
+
}
|
|
49
|
+
get isOpen() {
|
|
50
|
+
return this.dialog.hasAttribute('open');
|
|
51
|
+
}
|
|
52
|
+
setLabels(open, close) {
|
|
53
|
+
this.button.textContent = open;
|
|
54
|
+
this.closeButton.textContent = close;
|
|
55
|
+
}
|
|
56
|
+
/** Puts the list in the panel (`true`) or back in its place in `<nav>` (`false`). */
|
|
57
|
+
moveList(toPanel) {
|
|
58
|
+
if (toPanel) {
|
|
59
|
+
if (this.#list.parentElement !== this.#inner)
|
|
60
|
+
this.#inner.append(this.#list);
|
|
61
|
+
}
|
|
62
|
+
else if (this.#list.parentElement !== this.#nav) {
|
|
63
|
+
this.#nav.insertBefore(this.#list, this.#listNext?.parentNode === this.#nav ? this.#listNext : null);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Removes the button and the panel and puts the list back. */
|
|
67
|
+
detach() {
|
|
68
|
+
if (this.isOpen)
|
|
69
|
+
this.dialog.close();
|
|
70
|
+
this.moveList(false);
|
|
71
|
+
this.button.remove();
|
|
72
|
+
this.dialog.remove();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=overlay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overlay.js","sourceRoot":"","sources":["../src/overlay.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEpC;;;;GAIG;AACH,MAAM,OAAO,OAAO;IACT,MAAM,CAAoB;IAC1B,MAAM,CAAoB;IAC1B,WAAW,CAAoB;IAE/B,IAAI,CAAc;IAClB,KAAK,CAAmB;IACxB,MAAM,CAAiB;IAChC,iFAAiF;IACxE,SAAS,CAAc;IAEhC,YAAY,GAAgB,EAAE,IAAsB;QAClD,MAAM,GAAG,GAAG,GAAG,CAAC,aAAa,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC;QAChB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QAClB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC;QAElC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,eAAe,CAAC;QACxC,IAAI,CAAC,MAAM,CAAC,EAAE,GAAG,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;QAC1C,0EAA0E;QAC1E,KAAK,MAAM,SAAS,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC,EAAE,CAAC;YAC1D,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;YAC1C,IAAI,KAAK,EAAE,CAAC;gBACV,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;gBAC3C,MAAM;YACR,CAAC;QACH,CAAC;QAED,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACvC,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,qBAAqB,CAAC;QAC9C,IAAI,CAAC,WAAW,GAAG,GAAG,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAC/C,IAAI,CAAC,WAAW,CAAC,IAAI,GAAG,QAAQ,CAAC;QACjC,IAAI,CAAC,WAAW,CAAC,SAAS,GAAG,eAAe,CAAC;QAC7C,6FAA6F;QAC7F,+FAA+F;QAC/F,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACrC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAEhC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,IAAI,GAAG,QAAQ,CAAC;QAC5B,IAAI,CAAC,MAAM,CAAC,SAAS,GAAG,qBAAqB,CAAC;QAC9C,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,eAAe,EAAE,OAAO,CAAC,CAAC;QACnD,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,eAAe,EAAE,QAAQ,CAAC,CAAC;QACpD,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAE1D,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACxC,CAAC;IAED,IAAI,MAAM;QACR,OAAO,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IAC1C,CAAC;IAED,SAAS,CAAC,IAAY,EAAE,KAAa;QACnC,IAAI,CAAC,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC;QAC/B,IAAI,CAAC,WAAW,CAAC,WAAW,GAAG,KAAK,CAAC;IACvC,CAAC;IAED,qFAAqF;IACrF,QAAQ,CAAC,OAAgB;QACvB,IAAI,OAAO,EAAE,CAAC;YACZ,IAAI,IAAI,CAAC,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC,MAAM;gBAAE,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC/E,CAAC;aAAM,IAAI,IAAI,CAAC,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC,IAAI,EAAE,CAAC;YAClD,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,UAAU,KAAK,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACvG,CAAC;IACH,CAAC;IAED,+DAA+D;IAC/D,MAAM;QACJ,IAAI,IAAI,CAAC,MAAM;YAAE,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACrC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QACrB,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;IACvB,CAAC;CACF","sourcesContent":["import { uniqueId } from './dom.js';\n\n/**\n * The menu button and the `<dialog>` panel of one navigation. Both exist while the navigation is\n * enhanced; the navigation's own `<ul>` moves into the panel in overlay mode and back to its place\n * in `<nav>` in inline mode, so there is only ever one list.\n */\nexport class Overlay {\n readonly button: HTMLButtonElement;\n readonly dialog: HTMLDialogElement;\n readonly closeButton: HTMLButtonElement;\n\n readonly #nav: HTMLElement;\n readonly #list: HTMLUListElement;\n readonly #inner: HTMLDivElement;\n /** The node the list sat before in `<nav>`, to put it back in the same place. */\n readonly #listNext: Node | null;\n\n constructor(nav: HTMLElement, list: HTMLUListElement) {\n const doc = nav.ownerDocument;\n this.#nav = nav;\n this.#list = list;\n this.#listNext = list.nextSibling;\n\n this.dialog = doc.createElement('dialog');\n this.dialog.className = 'ui-nav__panel';\n this.dialog.id = uniqueId(doc, 'ui-nav-');\n // The dialog takes the navigation's name, whichever way the nav is named.\n for (const attribute of ['aria-labelledby', 'aria-label']) {\n const value = nav.getAttribute(attribute);\n if (value) {\n this.dialog.setAttribute(attribute, value);\n break;\n }\n }\n\n this.#inner = doc.createElement('div');\n this.#inner.className = 'ui-nav__panel-inner';\n this.closeButton = doc.createElement('button');\n this.closeButton.type = 'button';\n this.closeButton.className = 'ui-nav__close';\n // No autofocus: as the first focusable element in the dialog, showModal() focuses it anyway,\n // and an autofocus element inserted while something else has focus makes Chrome log a message.\n this.#inner.append(this.closeButton);\n this.dialog.append(this.#inner);\n\n this.button = doc.createElement('button');\n this.button.type = 'button';\n this.button.className = 'ui-nav__menu-button';\n this.button.setAttribute('aria-expanded', 'false');\n this.button.setAttribute('aria-haspopup', 'dialog');\n this.button.setAttribute('aria-controls', this.dialog.id);\n\n nav.prepend(this.button, this.dialog);\n }\n\n get isOpen(): boolean {\n return this.dialog.hasAttribute('open');\n }\n\n setLabels(open: string, close: string): void {\n this.button.textContent = open;\n this.closeButton.textContent = close;\n }\n\n /** Puts the list in the panel (`true`) or back in its place in `<nav>` (`false`). */\n moveList(toPanel: boolean): void {\n if (toPanel) {\n if (this.#list.parentElement !== this.#inner) this.#inner.append(this.#list);\n } else if (this.#list.parentElement !== this.#nav) {\n this.#nav.insertBefore(this.#list, this.#listNext?.parentNode === this.#nav ? this.#listNext : null);\n }\n }\n\n /** Removes the button and the panel and puts the list back. */\n detach(): void {\n if (this.isOpen) this.dialog.close();\n this.moveList(false);\n this.button.remove();\n this.dialog.remove();\n }\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The text of a label element, trimmed and with whitespace runs collapsed to one space. */
|
|
2
|
+
export declare function labelText(el: Element): string;
|
|
3
|
+
export type OpenedBy = 'click' | 'hover';
|
|
4
|
+
/**
|
|
5
|
+
* Called whenever a submenu opens, closes or is pinned; `by` is how it was (or had been) opened,
|
|
6
|
+
* or `pin` when a click keeps a hover-opened submenu open.
|
|
7
|
+
*/
|
|
8
|
+
export type SubmenuChange = (change: {
|
|
9
|
+
open: boolean;
|
|
10
|
+
label: string;
|
|
11
|
+
by: OpenedBy | 'pin';
|
|
12
|
+
}) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Adds and removes the disclosure toggles of every submenu in one `<nav>`. A parent with a link
|
|
15
|
+
* keeps the link and gets a separate toggle after it (a split button); a top-level parent with a
|
|
16
|
+
* `<span>` has the span swapped for a toggle with the same content, and the same span put back on
|
|
17
|
+
* removal. A `<span>` parent below the top level is a group: it never gets a toggle, its list is
|
|
18
|
+
* always shown with its parent, and the span names that list (`aria-labelledby`).
|
|
19
|
+
*/
|
|
20
|
+
export declare class Submenus {
|
|
21
|
+
#private;
|
|
22
|
+
/** How each open submenu was opened. */
|
|
23
|
+
readonly openedBy: Map<HTMLLIElement, OpenedBy>;
|
|
24
|
+
constructor(nav: HTMLElement, onChange: SubmenuChange);
|
|
25
|
+
/** Adds the toggles when `present` and removes them otherwise; `labelSubmenu` names split toggles. */
|
|
26
|
+
syncToggles(present: boolean, labelSubmenu: string): void;
|
|
27
|
+
/** Every list item that has a submenu. */
|
|
28
|
+
get parentItems(): HTMLLIElement[];
|
|
29
|
+
/** Keeps a hover-opened submenu open as if it had been clicked open. */
|
|
30
|
+
pin(li: HTMLLIElement): void;
|
|
31
|
+
/** The parent whose toggle is `toggle`, if any. */
|
|
32
|
+
parentOfToggle(toggle: Element): HTMLLIElement | undefined;
|
|
33
|
+
toggleOf(li: HTMLLIElement): HTMLButtonElement | undefined;
|
|
34
|
+
isOpen(li: HTMLLIElement): boolean;
|
|
35
|
+
expand(li: HTMLLIElement, by: OpenedBy): void;
|
|
36
|
+
/** Closes the submenu of `li` and every submenu open inside it. */
|
|
37
|
+
collapse(li: HTMLLIElement): void;
|
|
38
|
+
/** Closes every open submenu, or only those inside `scope`. */
|
|
39
|
+
collapseAll(scope?: Element): void;
|
|
40
|
+
/** Closes the open submenus of the other items on the same level as `li`. */
|
|
41
|
+
collapseSiblings(li: HTMLLIElement): void;
|
|
42
|
+
/** The nearest open parent around `element` (a parent's own toggle counts as inside it). */
|
|
43
|
+
innermostOpen(element: Element): HTMLLIElement | undefined;
|
|
44
|
+
/** The top-level item of this menu that contains `element`, if any. */
|
|
45
|
+
topLevelItemOf(element: Element): HTMLLIElement | undefined;
|
|
46
|
+
destroy(): void;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=submenus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"submenus.d.ts","sourceRoot":"","sources":["../src/submenus.ts"],"names":[],"mappings":"AAEA,4FAA4F;AAC5F,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAE7C;AAID,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;AAEzC;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,QAAQ,GAAG,KAAK,CAAA;CAAE,KAAK,IAAI,CAAC;AAUrG;;;;;;GAMG;AACH,qBAAa,QAAQ;;IASnB,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,+BAAsC;gBAE3C,GAAG,EAAE,WAAW,EAAE,QAAQ,EAAE,aAAa;IAgBrD,sGAAsG;IACtG,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI;IAUzD,0CAA0C;IAC1C,IAAI,WAAW,IAAI,aAAa,EAAE,CAEjC;IAED,wEAAwE;IACxE,GAAG,CAAC,EAAE,EAAE,aAAa,GAAG,IAAI;IAM5B,mDAAmD;IACnD,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,aAAa,GAAG,SAAS;IAK1D,QAAQ,CAAC,EAAE,EAAE,aAAa,GAAG,iBAAiB,GAAG,SAAS;IAI1D,MAAM,CAAC,EAAE,EAAE,aAAa,GAAG,OAAO;IAIlC,MAAM,CAAC,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,QAAQ,GAAG,IAAI;IAQ7C,mEAAmE;IACnE,QAAQ,CAAC,EAAE,EAAE,aAAa,GAAG,IAAI;IAWjC,+DAA+D;IAC/D,WAAW,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI;IAOlC,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,EAAE,aAAa,GAAG,IAAI;IAMzC,4FAA4F;IAC5F,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,SAAS;IAO1D,uEAAuE;IACvE,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,SAAS;IAQ3D,OAAO,IAAI,IAAI;CAwEhB"}
|
package/dist/submenus.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createChevron, createVisuallyHidden, uniqueId } from './dom.js';
|
|
2
|
+
/** The text of a label element, trimmed and with whitespace runs collapsed to one space. */
|
|
3
|
+
export function labelText(el) {
|
|
4
|
+
return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
5
|
+
}
|
|
6
|
+
const FOCUSABLE = 'a[href], button:not([disabled])';
|
|
7
|
+
/**
|
|
8
|
+
* Adds and removes the disclosure toggles of every submenu in one `<nav>`. A parent with a link
|
|
9
|
+
* keeps the link and gets a separate toggle after it (a split button); a top-level parent with a
|
|
10
|
+
* `<span>` has the span swapped for a toggle with the same content, and the same span put back on
|
|
11
|
+
* removal. A `<span>` parent below the top level is a group: it never gets a toggle, its list is
|
|
12
|
+
* always shown with its parent, and the span names that list (`aria-labelledby`).
|
|
13
|
+
*/
|
|
14
|
+
export class Submenus {
|
|
15
|
+
#nav;
|
|
16
|
+
#doc;
|
|
17
|
+
#parents;
|
|
18
|
+
#toggles = new Map();
|
|
19
|
+
#generatedIds = new Set();
|
|
20
|
+
#labelledGroups = new Set();
|
|
21
|
+
#onChange;
|
|
22
|
+
#labelTemplate = '';
|
|
23
|
+
/** How each open submenu was opened. */
|
|
24
|
+
openedBy = new Map();
|
|
25
|
+
constructor(nav, onChange) {
|
|
26
|
+
this.#nav = nav;
|
|
27
|
+
this.#doc = nav.ownerDocument;
|
|
28
|
+
this.#onChange = onChange;
|
|
29
|
+
this.#parents = [];
|
|
30
|
+
for (const li of nav.querySelectorAll('li')) {
|
|
31
|
+
const submenu = li.querySelector(':scope > ul');
|
|
32
|
+
const label = li.querySelector(':scope > a, :scope > span');
|
|
33
|
+
if (!submenu || !label)
|
|
34
|
+
continue;
|
|
35
|
+
const outer = li.parentElement?.closest('li');
|
|
36
|
+
const isGroup = label.localName === 'span' && outer != null && nav.contains(outer);
|
|
37
|
+
if (isGroup)
|
|
38
|
+
this.#nameGroup(submenu, label);
|
|
39
|
+
else
|
|
40
|
+
this.#parents.push({ li, submenu, label });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Adds the toggles when `present` and removes them otherwise; `labelSubmenu` names split toggles. */
|
|
44
|
+
syncToggles(present, labelSubmenu) {
|
|
45
|
+
this.#labelTemplate = labelSubmenu;
|
|
46
|
+
for (const parent of this.#parents) {
|
|
47
|
+
const toggle = this.#toggles.get(parent.li);
|
|
48
|
+
if (present && !toggle)
|
|
49
|
+
this.#addToggle(parent);
|
|
50
|
+
else if (!present && toggle)
|
|
51
|
+
this.#removeToggle(parent, toggle);
|
|
52
|
+
else if (toggle)
|
|
53
|
+
this.#renameSplitToggle(parent, toggle);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Every list item that has a submenu. */
|
|
57
|
+
get parentItems() {
|
|
58
|
+
return this.#parents.map((parent) => parent.li);
|
|
59
|
+
}
|
|
60
|
+
/** Keeps a hover-opened submenu open as if it had been clicked open. */
|
|
61
|
+
pin(li) {
|
|
62
|
+
if (this.openedBy.get(li) !== 'hover')
|
|
63
|
+
return;
|
|
64
|
+
this.openedBy.set(li, 'click');
|
|
65
|
+
this.#notify(li, true, 'pin');
|
|
66
|
+
}
|
|
67
|
+
/** The parent whose toggle is `toggle`, if any. */
|
|
68
|
+
parentOfToggle(toggle) {
|
|
69
|
+
for (const [li, button] of this.#toggles)
|
|
70
|
+
if (button === toggle)
|
|
71
|
+
return li;
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
toggleOf(li) {
|
|
75
|
+
return this.#toggles.get(li);
|
|
76
|
+
}
|
|
77
|
+
isOpen(li) {
|
|
78
|
+
return this.#toggles.get(li)?.getAttribute('aria-expanded') === 'true';
|
|
79
|
+
}
|
|
80
|
+
expand(li, by) {
|
|
81
|
+
const toggle = this.#toggles.get(li);
|
|
82
|
+
if (!toggle || this.isOpen(li))
|
|
83
|
+
return;
|
|
84
|
+
toggle.setAttribute('aria-expanded', 'true');
|
|
85
|
+
this.openedBy.set(li, by);
|
|
86
|
+
this.#notify(li, true, by);
|
|
87
|
+
}
|
|
88
|
+
/** Closes the submenu of `li` and every submenu open inside it. */
|
|
89
|
+
collapse(li) {
|
|
90
|
+
for (const parent of this.#parents) {
|
|
91
|
+
if (parent.li !== li && !li.contains(parent.li))
|
|
92
|
+
continue;
|
|
93
|
+
if (!this.isOpen(parent.li))
|
|
94
|
+
continue;
|
|
95
|
+
const by = this.openedBy.get(parent.li) ?? 'click';
|
|
96
|
+
this.#toggles.get(parent.li)?.setAttribute('aria-expanded', 'false');
|
|
97
|
+
this.openedBy.delete(parent.li);
|
|
98
|
+
this.#notify(parent.li, false, by);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Closes every open submenu, or only those inside `scope`. */
|
|
102
|
+
collapseAll(scope) {
|
|
103
|
+
for (const parent of this.#parents) {
|
|
104
|
+
if (scope && !scope.contains(parent.li))
|
|
105
|
+
continue;
|
|
106
|
+
this.collapse(parent.li);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Closes the open submenus of the other items on the same level as `li`. */
|
|
110
|
+
collapseSiblings(li) {
|
|
111
|
+
for (const sibling of li.parentElement?.children ?? []) {
|
|
112
|
+
if (sibling !== li && sibling instanceof this.#doc.defaultView.HTMLLIElement)
|
|
113
|
+
this.collapse(sibling);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The nearest open parent around `element` (a parent's own toggle counts as inside it). */
|
|
117
|
+
innermostOpen(element) {
|
|
118
|
+
for (let node = element; node; node = node.parentElement) {
|
|
119
|
+
if (node instanceof this.#doc.defaultView.HTMLLIElement && this.isOpen(node))
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/** The top-level item of this menu that contains `element`, if any. */
|
|
125
|
+
topLevelItemOf(element) {
|
|
126
|
+
let item;
|
|
127
|
+
for (let node = element; node && node !== this.#nav; node = node.parentElement) {
|
|
128
|
+
if (node instanceof this.#doc.defaultView.HTMLLIElement)
|
|
129
|
+
item = node;
|
|
130
|
+
}
|
|
131
|
+
return item;
|
|
132
|
+
}
|
|
133
|
+
destroy() {
|
|
134
|
+
for (const parent of this.#parents) {
|
|
135
|
+
const toggle = this.#toggles.get(parent.li);
|
|
136
|
+
if (toggle)
|
|
137
|
+
this.#removeToggle(parent, toggle);
|
|
138
|
+
}
|
|
139
|
+
for (const submenu of this.#labelledGroups)
|
|
140
|
+
submenu.removeAttribute('aria-labelledby');
|
|
141
|
+
this.#labelledGroups.clear();
|
|
142
|
+
for (const element of this.#generatedIds)
|
|
143
|
+
element.removeAttribute('id');
|
|
144
|
+
this.#generatedIds.clear();
|
|
145
|
+
}
|
|
146
|
+
/** Names a group's list after its label, unless the markup already names it. */
|
|
147
|
+
#nameGroup(submenu, label) {
|
|
148
|
+
if (submenu.hasAttribute('aria-labelledby') || submenu.hasAttribute('aria-label'))
|
|
149
|
+
return;
|
|
150
|
+
if (!label.id) {
|
|
151
|
+
label.id = uniqueId(this.#doc, 'ui-nav-');
|
|
152
|
+
this.#generatedIds.add(label);
|
|
153
|
+
}
|
|
154
|
+
submenu.setAttribute('aria-labelledby', label.id);
|
|
155
|
+
this.#labelledGroups.add(submenu);
|
|
156
|
+
}
|
|
157
|
+
#addToggle(parent) {
|
|
158
|
+
const { li, submenu, label } = parent;
|
|
159
|
+
if (!submenu.id) {
|
|
160
|
+
submenu.id = uniqueId(this.#doc, 'ui-nav-');
|
|
161
|
+
this.#generatedIds.add(submenu);
|
|
162
|
+
}
|
|
163
|
+
const toggle = this.#doc.createElement('button');
|
|
164
|
+
toggle.type = 'button';
|
|
165
|
+
toggle.className = 'ui-nav__toggle';
|
|
166
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
167
|
+
toggle.setAttribute('aria-controls', submenu.id);
|
|
168
|
+
if (label instanceof this.#doc.defaultView.HTMLAnchorElement) {
|
|
169
|
+
toggle.append(createVisuallyHidden(this.#doc, this.#splitLabel(label)));
|
|
170
|
+
label.after(toggle);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
toggle.classList.add('ui-nav__toggle--label');
|
|
174
|
+
toggle.append(...[...label.childNodes].map((node) => node.cloneNode(true)));
|
|
175
|
+
label.replaceWith(toggle);
|
|
176
|
+
}
|
|
177
|
+
toggle.append(createChevron(this.#doc));
|
|
178
|
+
this.#toggles.set(li, toggle);
|
|
179
|
+
}
|
|
180
|
+
#removeToggle(parent, toggle) {
|
|
181
|
+
const hadFocus = this.#doc.activeElement === toggle;
|
|
182
|
+
if (parent.label.isConnected)
|
|
183
|
+
toggle.remove();
|
|
184
|
+
else
|
|
185
|
+
toggle.replaceWith(parent.label);
|
|
186
|
+
this.#toggles.delete(parent.li);
|
|
187
|
+
this.openedBy.delete(parent.li);
|
|
188
|
+
if (!hadFocus)
|
|
189
|
+
return;
|
|
190
|
+
const target = parent.label.matches(FOCUSABLE)
|
|
191
|
+
? parent.label
|
|
192
|
+
: parent.submenu.querySelector(FOCUSABLE);
|
|
193
|
+
target?.focus();
|
|
194
|
+
}
|
|
195
|
+
#renameSplitToggle(parent, toggle) {
|
|
196
|
+
const hidden = toggle.querySelector('.ui-nav__visually-hidden');
|
|
197
|
+
if (hidden)
|
|
198
|
+
hidden.textContent = this.#splitLabel(parent.label);
|
|
199
|
+
}
|
|
200
|
+
#notify(li, open, by) {
|
|
201
|
+
const parent = this.#parents.find((candidate) => candidate.li === li);
|
|
202
|
+
if (parent)
|
|
203
|
+
this.#onChange({ open, label: labelText(parent.label), by });
|
|
204
|
+
}
|
|
205
|
+
#splitLabel(link) {
|
|
206
|
+
return this.#labelTemplate.replaceAll('{label}', labelText(link));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=submenus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"submenus.js","sourceRoot":"","sources":["../src/submenus.ts"],"names":[],"mappings":"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","sourcesContent":["import { createChevron, createVisuallyHidden, uniqueId } from './dom.js';\n\n/** The text of a label element, trimmed and with whitespace runs collapsed to one space. */\nexport function labelText(el: Element): string {\n return (el.textContent ?? '').replace(/\\s+/g, ' ').trim();\n}\n\nconst FOCUSABLE = 'a[href], button:not([disabled])';\n\nexport type OpenedBy = 'click' | 'hover';\n\n/**\n * Called whenever a submenu opens, closes or is pinned; `by` is how it was (or had been) opened,\n * or `pin` when a click keeps a hover-opened submenu open.\n */\nexport type SubmenuChange = (change: { open: boolean; label: string; by: OpenedBy | 'pin' }) => void;\n\n/** A list item with a submenu, and the element that labels it in the rendered markup. */\ninterface Parent {\n li: HTMLLIElement;\n submenu: HTMLUListElement;\n /** The link of a split parent, or the `<span>` of a parent without a page. */\n label: HTMLAnchorElement | HTMLSpanElement;\n}\n\n/**\n * Adds and removes the disclosure toggles of every submenu in one `<nav>`. A parent with a link\n * keeps the link and gets a separate toggle after it (a split button); a top-level parent with a\n * `<span>` has the span swapped for a toggle with the same content, and the same span put back on\n * removal. A `<span>` parent below the top level is a group: it never gets a toggle, its list is\n * always shown with its parent, and the span names that list (`aria-labelledby`).\n */\nexport class Submenus {\n readonly #nav: HTMLElement;\n readonly #doc: Document;\n readonly #parents: Parent[];\n readonly #toggles = new Map<HTMLLIElement, HTMLButtonElement>();\n readonly #generatedIds = new Set<HTMLElement>();\n readonly #labelledGroups = new Set<HTMLUListElement>();\n readonly #onChange: SubmenuChange;\n #labelTemplate = '';\n /** How each open submenu was opened. */\n readonly openedBy = new Map<HTMLLIElement, OpenedBy>();\n\n constructor(nav: HTMLElement, onChange: SubmenuChange) {\n this.#nav = nav;\n this.#doc = nav.ownerDocument;\n this.#onChange = onChange;\n this.#parents = [];\n for (const li of nav.querySelectorAll('li')) {\n const submenu = li.querySelector<HTMLUListElement>(':scope > ul');\n const label = li.querySelector<HTMLAnchorElement | HTMLSpanElement>(':scope > a, :scope > span');\n if (!submenu || !label) continue;\n const outer = li.parentElement?.closest('li');\n const isGroup = label.localName === 'span' && outer != null && nav.contains(outer);\n if (isGroup) this.#nameGroup(submenu, label);\n else this.#parents.push({ li, submenu, label } as Parent);\n }\n }\n\n /** Adds the toggles when `present` and removes them otherwise; `labelSubmenu` names split toggles. */\n syncToggles(present: boolean, labelSubmenu: string): void {\n this.#labelTemplate = labelSubmenu;\n for (const parent of this.#parents) {\n const toggle = this.#toggles.get(parent.li);\n if (present && !toggle) this.#addToggle(parent);\n else if (!present && toggle) this.#removeToggle(parent, toggle);\n else if (toggle) this.#renameSplitToggle(parent, toggle);\n }\n }\n\n /** Every list item that has a submenu. */\n get parentItems(): HTMLLIElement[] {\n return this.#parents.map((parent) => parent.li);\n }\n\n /** Keeps a hover-opened submenu open as if it had been clicked open. */\n pin(li: HTMLLIElement): void {\n if (this.openedBy.get(li) !== 'hover') return;\n this.openedBy.set(li, 'click');\n this.#notify(li, true, 'pin');\n }\n\n /** The parent whose toggle is `toggle`, if any. */\n parentOfToggle(toggle: Element): HTMLLIElement | undefined {\n for (const [li, button] of this.#toggles) if (button === toggle) return li;\n return undefined;\n }\n\n toggleOf(li: HTMLLIElement): HTMLButtonElement | undefined {\n return this.#toggles.get(li);\n }\n\n isOpen(li: HTMLLIElement): boolean {\n return this.#toggles.get(li)?.getAttribute('aria-expanded') === 'true';\n }\n\n expand(li: HTMLLIElement, by: OpenedBy): void {\n const toggle = this.#toggles.get(li);\n if (!toggle || this.isOpen(li)) return;\n toggle.setAttribute('aria-expanded', 'true');\n this.openedBy.set(li, by);\n this.#notify(li, true, by);\n }\n\n /** Closes the submenu of `li` and every submenu open inside it. */\n collapse(li: HTMLLIElement): void {\n for (const parent of this.#parents) {\n if (parent.li !== li && !li.contains(parent.li)) continue;\n if (!this.isOpen(parent.li)) continue;\n const by = this.openedBy.get(parent.li) ?? 'click';\n this.#toggles.get(parent.li)?.setAttribute('aria-expanded', 'false');\n this.openedBy.delete(parent.li);\n this.#notify(parent.li, false, by);\n }\n }\n\n /** Closes every open submenu, or only those inside `scope`. */\n collapseAll(scope?: Element): void {\n for (const parent of this.#parents) {\n if (scope && !scope.contains(parent.li)) continue;\n this.collapse(parent.li);\n }\n }\n\n /** Closes the open submenus of the other items on the same level as `li`. */\n collapseSiblings(li: HTMLLIElement): void {\n for (const sibling of li.parentElement?.children ?? []) {\n if (sibling !== li && sibling instanceof this.#doc.defaultView!.HTMLLIElement) this.collapse(sibling);\n }\n }\n\n /** The nearest open parent around `element` (a parent's own toggle counts as inside it). */\n innermostOpen(element: Element): HTMLLIElement | undefined {\n for (let node: Element | null = element; node; node = node.parentElement) {\n if (node instanceof this.#doc.defaultView!.HTMLLIElement && this.isOpen(node)) return node;\n }\n return undefined;\n }\n\n /** The top-level item of this menu that contains `element`, if any. */\n topLevelItemOf(element: Element): HTMLLIElement | undefined {\n let item: HTMLLIElement | undefined;\n for (let node: Element | null = element; node && node !== this.#nav; node = node.parentElement) {\n if (node instanceof this.#doc.defaultView!.HTMLLIElement) item = node;\n }\n return item;\n }\n\n destroy(): void {\n for (const parent of this.#parents) {\n const toggle = this.#toggles.get(parent.li);\n if (toggle) this.#removeToggle(parent, toggle);\n }\n for (const submenu of this.#labelledGroups) submenu.removeAttribute('aria-labelledby');\n this.#labelledGroups.clear();\n for (const element of this.#generatedIds) element.removeAttribute('id');\n this.#generatedIds.clear();\n }\n\n /** Names a group's list after its label, unless the markup already names it. */\n #nameGroup(submenu: HTMLUListElement, label: HTMLSpanElement): void {\n if (submenu.hasAttribute('aria-labelledby') || submenu.hasAttribute('aria-label')) return;\n if (!label.id) {\n label.id = uniqueId(this.#doc, 'ui-nav-');\n this.#generatedIds.add(label);\n }\n submenu.setAttribute('aria-labelledby', label.id);\n this.#labelledGroups.add(submenu);\n }\n\n #addToggle(parent: Parent): void {\n const { li, submenu, label } = parent;\n if (!submenu.id) {\n submenu.id = uniqueId(this.#doc, 'ui-nav-');\n this.#generatedIds.add(submenu);\n }\n const toggle = this.#doc.createElement('button');\n toggle.type = 'button';\n toggle.className = 'ui-nav__toggle';\n toggle.setAttribute('aria-expanded', 'false');\n toggle.setAttribute('aria-controls', submenu.id);\n\n if (label instanceof this.#doc.defaultView!.HTMLAnchorElement) {\n toggle.append(createVisuallyHidden(this.#doc, this.#splitLabel(label)));\n label.after(toggle);\n } else {\n toggle.classList.add('ui-nav__toggle--label');\n toggle.append(...[...label.childNodes].map((node) => node.cloneNode(true)));\n label.replaceWith(toggle);\n }\n toggle.append(createChevron(this.#doc));\n this.#toggles.set(li, toggle);\n }\n\n #removeToggle(parent: Parent, toggle: HTMLButtonElement): void {\n const hadFocus = this.#doc.activeElement === toggle;\n if (parent.label.isConnected) toggle.remove();\n else toggle.replaceWith(parent.label);\n this.#toggles.delete(parent.li);\n this.openedBy.delete(parent.li);\n if (!hadFocus) return;\n const target = parent.label.matches(FOCUSABLE)\n ? parent.label\n : parent.submenu.querySelector<HTMLElement>(FOCUSABLE);\n target?.focus();\n }\n\n #renameSplitToggle(parent: Parent, toggle: HTMLButtonElement): void {\n const hidden = toggle.querySelector('.ui-nav__visually-hidden');\n if (hidden) hidden.textContent = this.#splitLabel(parent.label);\n }\n\n #notify(li: HTMLLIElement, open: boolean, by: OpenedBy | 'pin'): void {\n const parent = this.#parents.find((candidate) => candidate.li === li);\n if (parent) this.#onChange({ open, label: labelText(parent.label), by });\n }\n\n #splitLabel(link: Element): string {\n return this.#labelTemplate.replaceAll('{label}', labelText(link));\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vakwerk/navigation",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Accessible site navigation with dropdown submenus and an overlay dialog, as a light-DOM custom element or init().",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/navigation.d.ts",
|
|
9
|
+
"import": "./dist/navigation.js"
|
|
10
|
+
},
|
|
11
|
+
"./navigation.css": "./src/navigation.css"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/navigation.css"
|
|
16
|
+
],
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@axe-core/playwright": "^4.14.0",
|
|
19
|
+
"@playwright/test": "^1.64.1",
|
|
20
|
+
"jsdom": "^30.1.3"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"pretest": "tsc",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"test:e2e": "tsc && playwright test",
|
|
28
|
+
"check:types": "tsc --noEmit",
|
|
29
|
+
"lint": "echo 'no linter configured yet'"
|
|
30
|
+
}
|
|
6
31
|
}
|