@agencecinq/disclosure-navigation 0.0.0-stage → 1.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 +310 -2
- package/dist/disclosure-navigation.d.ts +29 -0
- package/dist/disclosure-navigation.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/utils.d.ts +21 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +49 -3
package/README.md
CHANGED
|
@@ -1,3 +1,311 @@
|
|
|
1
|
-
|
|
1
|
+
[](https://www.npmjs.com/package/@agencecinq/disclosure-navigation)
|
|
2
|
+
[](https://www.npmjs.com/package/@agencecinq/disclosure-navigation)
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
# @agencecinq/disclosure-navigation
|
|
5
|
+
|
|
6
|
+
> Accessible, WAI-ARIA disclosure navigation as a lightweight Web Component.
|
|
7
|
+
|
|
8
|
+
A site navigation whose buttons show and hide submenus. `<cinq-disclosure-navigation>`
|
|
9
|
+
updates `aria-expanded` and `hidden`, keeps one branch open at a time, adds the keyboard
|
|
10
|
+
support of the APG example, and can open the submenus on hover.
|
|
11
|
+
|
|
12
|
+
Implementation follows the
|
|
13
|
+
[WAI-ARIA Authoring Practices disclosure navigation example](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/).
|
|
14
|
+
Inspired by [`@19h47/disclosure-navigation`](https://github.com/19h47/19h47-disclosure-navigation).
|
|
15
|
+
|
|
16
|
+
- **Navigation, not a menu.** A site navigation is a list of links. Buttons announce
|
|
17
|
+
_collapsed_ or _expanded_, links stay links. The ARIA `menu` role is meant for
|
|
18
|
+
application menus.
|
|
19
|
+
- **Your HTML is the source of truth.** The component reads `aria-expanded` and
|
|
20
|
+
`aria-controls`, and never adds a missing attribute.
|
|
21
|
+
- **Built on the platform.** Native `<button>`, the `hidden` attribute,
|
|
22
|
+
[`ariaControlsElements`](https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaControlsElements),
|
|
23
|
+
[`ToggleEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ToggleEvent) as popovers
|
|
24
|
+
fire it, `AbortController`. No dependency.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add @agencecinq/disclosure-navigation
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import "@agencecinq/disclosure-navigation";
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```html
|
|
39
|
+
<cinq-disclosure-navigation>
|
|
40
|
+
<nav aria-label="Main">
|
|
41
|
+
<ul>
|
|
42
|
+
<li><a href="/">Home</a></li>
|
|
43
|
+
<li>
|
|
44
|
+
<button type="button" aria-expanded="false" aria-controls="about-menu">About</button>
|
|
45
|
+
<ul id="about-menu" hidden>
|
|
46
|
+
<li><a href="/about/overview">Overview</a></li>
|
|
47
|
+
<li><a href="/about/team" aria-current="page">Team</a></li>
|
|
48
|
+
</ul>
|
|
49
|
+
</li>
|
|
50
|
+
</ul>
|
|
51
|
+
</nav>
|
|
52
|
+
</cinq-disclosure-navigation>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Importing the package registers the custom element. Connecting it only adds listeners.
|
|
56
|
+
|
|
57
|
+
> **HTML is the source of truth.** The component does not set a missing
|
|
58
|
+
> `aria-expanded`, `hidden` or label, nor warns about them. Use an accessibility linter
|
|
59
|
+
> (axe-core, Lighthouse) to catch invalid markup.
|
|
60
|
+
|
|
61
|
+
### Required markup
|
|
62
|
+
|
|
63
|
+
| Attribute | On | Role |
|
|
64
|
+
| --------------- | ----------- | ---------------------------------------------------------------------------------------- |
|
|
65
|
+
| `aria-expanded` | `<button>` | **Required.** The state. A button without it is ignored. The component updates it. |
|
|
66
|
+
| `aria-controls` | `<button>` | **Required.** ID(s) of the submenu(s), inside the host. Several IDs work together. |
|
|
67
|
+
| `hidden` | Submenu | Present while collapsed, or `hidden="until-found"` to keep its links searchable. |
|
|
68
|
+
| `aria-label` | `<nav>` | Names the landmark when the page has more than one navigation. |
|
|
69
|
+
| `aria-current` | `<a>` | Optional: marks the current page. |
|
|
70
|
+
|
|
71
|
+
To render a submenu open, write `aria-expanded="true"` on its button and leave out
|
|
72
|
+
`hidden`.
|
|
73
|
+
|
|
74
|
+
### Attributes
|
|
75
|
+
|
|
76
|
+
| Attribute | On | Effect |
|
|
77
|
+
| ------------------------ | ---- | ------------------------------------------------------------------------- |
|
|
78
|
+
| `data-hover` | Host | A mouse opens the submenus on hover. See [Opening on hover](#opening-on-hover). |
|
|
79
|
+
| `data-arrow-keys="false"` | Host | Leaves the arrow, Home and End keys to the browser. |
|
|
80
|
+
|
|
81
|
+
Both are read on each interaction: setting or removing them needs no call.
|
|
82
|
+
|
|
83
|
+
### Styling
|
|
84
|
+
|
|
85
|
+
Style the state the component writes:
|
|
86
|
+
|
|
87
|
+
```css
|
|
88
|
+
button[aria-expanded="true"]::after {
|
|
89
|
+
rotate: 225deg;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Needed only if your CSS gives .submenu a display value */
|
|
93
|
+
.submenu[hidden]:not([hidden="until-found"]) {
|
|
94
|
+
display: none;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Position submenus with logical properties, such as `inset-inline-start`, so they follow
|
|
99
|
+
the reading direction. The host is inline, as any custom element: give it a `display`
|
|
100
|
+
if you lay it out.
|
|
101
|
+
|
|
102
|
+
### Nested submenus
|
|
103
|
+
|
|
104
|
+
A submenu can hold buttons with their own submenus. Opening one keeps its parents open
|
|
105
|
+
and closes the other branches. Closing a submenu closes the ones nested in it.
|
|
106
|
+
|
|
107
|
+
### Opening on hover
|
|
108
|
+
|
|
109
|
+
With `data-hover`, a mouse opens a submenu when it enters its button, and closes it when
|
|
110
|
+
it leaves the button and the submenu. Touch and pen keep the click.
|
|
111
|
+
|
|
112
|
+
```html
|
|
113
|
+
<cinq-disclosure-navigation data-hover>…</cinq-disclosure-navigation>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **The click still works.** The first click after the mouse opened a submenu keeps it
|
|
117
|
+
open, as the user meant to open it. The next click closes it.
|
|
118
|
+
- **No gap.** The pointer goes from the button to the submenu: leave no space between
|
|
119
|
+
them, or fill it with the submenu's padding.
|
|
120
|
+
- **Escape closes it wherever the focus is.** Content shown on hover must be dismissible
|
|
121
|
+
without moving the pointer
|
|
122
|
+
([WCAG 1.4.13](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html)).
|
|
123
|
+
- **Hover only adds.** Keyboard and screen reader users open the same submenus with the
|
|
124
|
+
buttons.
|
|
125
|
+
|
|
126
|
+
### Find in page
|
|
127
|
+
|
|
128
|
+
Write `hidden="until-found"` on collapsed submenus and their links stay searchable. When
|
|
129
|
+
find in page or a link to a fragment reveals a submenu, the component opens it, nested
|
|
130
|
+
ones included, and keeps `until-found` when it closes again.
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<ul id="about-menu" hidden="until-found">…</ul>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A stylesheet that sets `[hidden] { display: none }` keeps the links out of the search:
|
|
137
|
+
Bootstrap's Reboot does, with `!important`, while Tailwind 4's preflight leaves
|
|
138
|
+
`until-found` out. Restore `display` for `[hidden="until-found"]`. The browser reveals
|
|
139
|
+
the submenu whatever a listener does:
|
|
140
|
+
canceling `beforetoggle` leaves the button collapsed next to a visible submenu.
|
|
141
|
+
|
|
142
|
+
## Keyboard
|
|
143
|
+
|
|
144
|
+
| Key | Focus on | Action |
|
|
145
|
+
| ------------------- | -------------------------- | ------------------------------------------------ |
|
|
146
|
+
| Tab / Shift + Tab | Anywhere | Native order. Collapsed submenus are skipped. |
|
|
147
|
+
| Enter / Space | Button | Toggles its submenu (native button activation). |
|
|
148
|
+
| Escape | Button or top-level link | Closes the open submenu. |
|
|
149
|
+
| Escape | Submenu | Closes the innermost one and focuses its button. |
|
|
150
|
+
| Escape | Outside the navigation | Closes the open submenu, focus stays. |
|
|
151
|
+
| ArrowDown | Expanded button, any level | Moves focus to the first item of its submenu. |
|
|
152
|
+
| Arrow keys | Top-level item | Previous or next button or link, wrapping. |
|
|
153
|
+
| Arrow keys | Submenu item | Previous or next item, wrapping. |
|
|
154
|
+
| Home / End | Top-level or submenu item | First or last item. |
|
|
155
|
+
|
|
156
|
+
Moving focus out of the navigation closes the open submenu. Keys pressed with Alt, Ctrl
|
|
157
|
+
or Meta are left to the browser, and so are arrow keys in a text field.
|
|
158
|
+
|
|
159
|
+
In a right-to-left context, ArrowLeft and ArrowRight swap. The component reads the
|
|
160
|
+
direction from the markup with `:dir(rtl)`: a `dir` attribute on the page or the
|
|
161
|
+
navigation is enough.
|
|
162
|
+
|
|
163
|
+
### In a dialog or a popover
|
|
164
|
+
|
|
165
|
+
Escape is also a
|
|
166
|
+
[close request](https://html.spec.whatwg.org/multipage/interaction.html#close-requests):
|
|
167
|
+
it closes the topmost dialog or popover. While a submenu is open and focus is in the
|
|
168
|
+
navigation, the component cancels the key press, so each press closes one layer: the
|
|
169
|
+
submenu, then the dialog. With every submenu closed, or focus elsewhere, the browser
|
|
170
|
+
handles the key. An Escape another script already canceled is left alone.
|
|
171
|
+
|
|
172
|
+
## API
|
|
173
|
+
|
|
174
|
+
| Member | Description |
|
|
175
|
+
| ----------------- | -------------------------------------------------------------------------------------------- |
|
|
176
|
+
| `init()` | Binds listeners. Runs on connect. Call `destroy()` first if already bound. |
|
|
177
|
+
| `destroy()` | Removes listeners. Runs on disconnect. The markup stays in its current state. |
|
|
178
|
+
| `open($button)` | Opens the submenu of `$button` and closes the other branches. Canceling its `beforetoggle` leaves every submenu as it is. |
|
|
179
|
+
| `close($button?)` | Closes the submenu of `$button`, or every open submenu. Focus inside a closing submenu moves to its button. |
|
|
180
|
+
| `toggle($button)` | Opens or closes the submenu of `$button`. |
|
|
181
|
+
|
|
182
|
+
Buttons are found inside the host with `button[aria-expanded][aria-controls]`, read from
|
|
183
|
+
the DOM on each interaction: adding or removing items needs no call. Closing the submenu
|
|
184
|
+
once a link is followed is up to you:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
const $navigation = document.querySelector("cinq-disclosure-navigation");
|
|
188
|
+
|
|
189
|
+
$navigation.addEventListener("click", (event) => {
|
|
190
|
+
if (event.target.closest("a")) {
|
|
191
|
+
$navigation.close();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Closing from outside
|
|
197
|
+
|
|
198
|
+
The open submenu closes when focus leaves the navigation, on Escape, and with
|
|
199
|
+
`data-hover` when the mouse leaves it. Safari does not focus a button on click, by
|
|
200
|
+
design: after opening a submenu with a click, clicking elsewhere moves no focus and the
|
|
201
|
+
submenu stays open. If you need it, close it yourself:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
document.addEventListener("click", (event) => {
|
|
205
|
+
if (!$navigation.contains(event.target)) {
|
|
206
|
+
$navigation.close();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Events
|
|
212
|
+
|
|
213
|
+
The component fires the platform's events, the way a popover does: a
|
|
214
|
+
[`ToggleEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ToggleEvent) named
|
|
215
|
+
`beforetoggle`, then `toggle`, at each submenu. The same listener works for `<details>`,
|
|
216
|
+
popovers and `<dialog>`.
|
|
217
|
+
|
|
218
|
+
| Event | Fires | Cancelable |
|
|
219
|
+
| -------------- | ----------------------------------------------------------------------------------------- | ----------------------- |
|
|
220
|
+
| `beforetoggle` | **Before** the DOM changes. | When the submenu opens. |
|
|
221
|
+
| `toggle` | **After**, in a later task. Changes made before it fires coalesce into one event, from the first old state to the last new state. | No. |
|
|
222
|
+
|
|
223
|
+
`event.target` is the submenu, `event.source` its button, and `oldState` and `newState`
|
|
224
|
+
are `'open'` or `'closed'`. As on the platform, the events do not bubble: otherwise a
|
|
225
|
+
submenu's `toggle` would reach the `ontoggle` of a `<details>` or a `<dialog>` holding the
|
|
226
|
+
navigation. Listen on the submenu, or on the host in the capture phase.
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
$navigation.addEventListener(
|
|
230
|
+
"beforetoggle",
|
|
231
|
+
(event) => {
|
|
232
|
+
if ("open" === event.newState && "services" === event.source.id) {
|
|
233
|
+
event.preventDefault(); // Services stays closed
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
{ capture: true },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
$navigation.addEventListener(
|
|
240
|
+
"toggle",
|
|
241
|
+
(event) => console.log(event.target.id, event.newState),
|
|
242
|
+
{ capture: true },
|
|
243
|
+
);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Canceling an opening leaves every submenu as it is, the open one included: the other
|
|
247
|
+
branches close after `beforetoggle`. Closing cannot be canceled. A button that controls
|
|
248
|
+
several elements fires at each one, and opens none of them when one opening is canceled.
|
|
249
|
+
In TypeScript, the DOM library already types both events as `ToggleEvent`.
|
|
250
|
+
|
|
251
|
+
## Browser support
|
|
252
|
+
|
|
253
|
+
Chrome and Edge 135, Firefox 136 and Safari 17: submenus are resolved with
|
|
254
|
+
[`ariaControlsElements`](https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaControlsElements)
|
|
255
|
+
and events are [`ToggleEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ToggleEvent)s.
|
|
256
|
+
`event.source` needs Chrome and Edge 140, Firefox 145 and Safari 26.5: older browsers
|
|
257
|
+
fire the events without it.
|
|
258
|
+
|
|
259
|
+
Find in page relies on `hidden="until-found"`: Chrome and Edge 102, Firefox 139,
|
|
260
|
+
Safari 26.2. Elsewhere it acts as `hidden`. The package is an ES module.
|
|
261
|
+
|
|
262
|
+
## Migration
|
|
263
|
+
|
|
264
|
+
### From `@19h47/disclosure-navigation` 5.x
|
|
265
|
+
|
|
266
|
+
The class becomes a custom element: the host replaces `$nav`. Markup inside it, styles,
|
|
267
|
+
methods and events do not change.
|
|
268
|
+
|
|
269
|
+
1. **Wrap the navigation in the host**, and drop the instance.
|
|
270
|
+
|
|
271
|
+
```diff
|
|
272
|
+
+ <cinq-disclosure-navigation>
|
|
273
|
+
<nav aria-label="Main">…</nav>
|
|
274
|
+
+ </cinq-disclosure-navigation>
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
```diff
|
|
278
|
+
- import DisclosureNavigation from "@19h47/disclosure-navigation";
|
|
279
|
+
-
|
|
280
|
+
- const disclosureNavigation = new DisclosureNavigation($nav);
|
|
281
|
+
-
|
|
282
|
+
- disclosureNavigation.init();
|
|
283
|
+
+ import "@agencecinq/disclosure-navigation";
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
2. **Call the methods on the host.** `open()`, `close()` and `toggle()` take the same
|
|
287
|
+
arguments. `init()` and `destroy()` run on connect and disconnect. `$el` is gone.
|
|
288
|
+
3. **Listen on the host.** `beforetoggle` and `toggle` still fire at the submenus,
|
|
289
|
+
without bubbling: listen on the host in the capture phase, instead of `$nav`.
|
|
290
|
+
4. **Replace `useArrowKeys = false`** with `data-arrow-keys="false"` on the host.
|
|
291
|
+
5. **Expect Escape outside the navigation** to close the open submenu, without
|
|
292
|
+
canceling the key.
|
|
293
|
+
|
|
294
|
+
### From several `<cinq-disclosure-button>`
|
|
295
|
+
|
|
296
|
+
A navigation built from one `<cinq-disclosure-button>` per entry, with a script that
|
|
297
|
+
closes the others or opens on hover, becomes one host.
|
|
298
|
+
|
|
299
|
+
1. **Remove the `<cinq-disclosure-button>` around each button**, and wrap the navigation
|
|
300
|
+
in `<cinq-disclosure-navigation>`. Add `data-hover` to open on hover.
|
|
301
|
+
2. **Remove the script.** One branch open at a time, Escape, closing when focus leaves
|
|
302
|
+
and hover are built in.
|
|
303
|
+
3. **Replace the `disclosure-button:*` listeners** with `beforetoggle` and `toggle` on
|
|
304
|
+
the host, in the capture phase.
|
|
305
|
+
|
|
306
|
+
## Development
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
pnpm --filter @agencecinq/disclosure-navigation build
|
|
310
|
+
pnpm --filter @agencecinq/disclosure-navigation test # Chromium, Firefox and WebKit
|
|
311
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disclosure navigation Web Component around a site navigation whose buttons show
|
|
3
|
+
* and hide submenus. The markup is the source of truth: `aria-expanded` on each
|
|
4
|
+
* button holds the state, and the submenus are the elements its `aria-controls`
|
|
5
|
+
* references. The component never adds a missing attribute; it only updates
|
|
6
|
+
* `aria-expanded` and `hidden` when the user interacts, and fires `beforetoggle`
|
|
7
|
+
* and `toggle` at the submenus, as popovers do.
|
|
8
|
+
*
|
|
9
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
10
|
+
* @see https://w3c.github.io/aria/#dom-ariamixin-ariacontrolselements
|
|
11
|
+
*/
|
|
12
|
+
export declare class DisclosureNavigation extends HTMLElement {
|
|
13
|
+
#private;
|
|
14
|
+
connectedCallback(): void;
|
|
15
|
+
disconnectedCallback(): void;
|
|
16
|
+
/** Binds listeners. Call `destroy()` first if already bound. */
|
|
17
|
+
init(): void;
|
|
18
|
+
/** Removes listeners. The markup stays in its current state. */
|
|
19
|
+
destroy(): void;
|
|
20
|
+
/**
|
|
21
|
+
* Opens the submenu of `$button` and closes the open ones outside its branch,
|
|
22
|
+
* as popovers do. Canceling its `beforetoggle` leaves every submenu as it is.
|
|
23
|
+
*/
|
|
24
|
+
open($button: HTMLButtonElement): void;
|
|
25
|
+
/** Closes the submenu of `$button` and the ones nested in it, or every open submenu. */
|
|
26
|
+
close($button?: HTMLButtonElement): void;
|
|
27
|
+
toggle($button: HTMLButtonElement): void;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=disclosure-navigation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"disclosure-navigation.d.ts","sourceRoot":"","sources":["../src/disclosure-navigation.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;GAUG;AACH,qBAAa,oBAAqB,SAAQ,WAAW;;IAOnD,iBAAiB,IAAI,IAAI;IAIzB,oBAAoB,IAAI,IAAI;IAI5B,gEAAgE;IAChE,IAAI,IAAI,IAAI;IAcZ,gEAAgE;IAChE,OAAO,IAAI,IAAI;IAKf;;;OAGG;IACH,IAAI,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI;IActC,wFAAwF;IACxF,KAAK,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,IAAI;IAUxC,MAAM,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI;CAkOzC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAElE,OAAO,EAAE,oBAAoB,EAAE,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
//#region src/utils.ts
|
|
2
|
+
var e = (e) => e === "open" ? "closed" : "open", t = /* @__PURE__ */ new WeakMap(), n = (t, n, r) => {
|
|
3
|
+
let i = new ToggleEvent("beforetoggle", {
|
|
4
|
+
cancelable: n === "open",
|
|
5
|
+
oldState: e(n),
|
|
6
|
+
newState: n,
|
|
7
|
+
source: r
|
|
8
|
+
});
|
|
9
|
+
return t.dispatchEvent(i);
|
|
10
|
+
}, r = (n, r, i) => {
|
|
11
|
+
let a = t.get(n), o = a?.oldState ?? e(r);
|
|
12
|
+
clearTimeout(a?.timeout);
|
|
13
|
+
let s = setTimeout(() => {
|
|
14
|
+
let e = new ToggleEvent("toggle", {
|
|
15
|
+
oldState: o,
|
|
16
|
+
newState: r,
|
|
17
|
+
source: i
|
|
18
|
+
});
|
|
19
|
+
t.delete(n), n.dispatchEvent(e);
|
|
20
|
+
});
|
|
21
|
+
t.set(n, {
|
|
22
|
+
oldState: o,
|
|
23
|
+
timeout: s
|
|
24
|
+
});
|
|
25
|
+
}, i = (e, t, n) => {
|
|
26
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
27
|
+
let r = t.length - 1, i = n === 0 ? r : n - 1, a = n === r ? 0 : n + 1, o = t[n].matches(":dir(rtl)"), s = {
|
|
28
|
+
ArrowUp: i,
|
|
29
|
+
ArrowLeft: o ? a : i,
|
|
30
|
+
ArrowDown: a,
|
|
31
|
+
ArrowRight: o ? i : a,
|
|
32
|
+
Home: 0,
|
|
33
|
+
End: r
|
|
34
|
+
}[e.key];
|
|
35
|
+
s !== void 0 && (e.preventDefault(), t[s].focus());
|
|
36
|
+
}, a = "button[aria-expanded][aria-controls]", o = `a[href], ${a}`, s = "a[href], button:enabled, [tabindex]:not([tabindex^=\"-\"])", c = (e) => e.ariaExpanded === "true", l = (e) => e.ariaControlsElements ?? [], u = (e, t) => l(e).some((e) => e.contains(t)), d = (e, t) => t !== null && (e.contains(t) || u(e, t)), f = (e, t) => l(e).every((r) => n(r, t, e)), p = class extends HTMLElement {
|
|
37
|
+
#e = null;
|
|
38
|
+
#t = /* @__PURE__ */ new WeakMap();
|
|
39
|
+
#n = null;
|
|
40
|
+
connectedCallback() {
|
|
41
|
+
this.init();
|
|
42
|
+
}
|
|
43
|
+
disconnectedCallback() {
|
|
44
|
+
this.destroy();
|
|
45
|
+
}
|
|
46
|
+
init() {
|
|
47
|
+
this.#e = new AbortController();
|
|
48
|
+
let { signal: e } = this.#e;
|
|
49
|
+
this.addEventListener("click", this.#l, { signal: e }), this.addEventListener("keydown", this.#h, { signal: e }), this.addEventListener("focusout", this.#f, { signal: e }), this.addEventListener("beforematch", this.#p, { signal: e }), this.addEventListener("pointerover", this.#u, { signal: e }), this.addEventListener("pointerout", this.#d, { signal: e }), document.addEventListener("keydown", this.#m, { signal: e });
|
|
50
|
+
}
|
|
51
|
+
destroy() {
|
|
52
|
+
this.#e?.abort(), this.#n = null;
|
|
53
|
+
}
|
|
54
|
+
open(e) {
|
|
55
|
+
if (c(e) || !f(e, "open")) return;
|
|
56
|
+
let t = this.#i.filter((t) => !u(t, e) && !u(e, t));
|
|
57
|
+
this.#o(t), this.#s(e, !0);
|
|
58
|
+
}
|
|
59
|
+
close(e) {
|
|
60
|
+
let t = e ? this.#i.filter((t) => t === e || u(e, t)) : this.#i;
|
|
61
|
+
this.#o(t);
|
|
62
|
+
}
|
|
63
|
+
toggle(e) {
|
|
64
|
+
return c(e) ? this.close(e) : this.open(e);
|
|
65
|
+
}
|
|
66
|
+
get #r() {
|
|
67
|
+
return [...this.querySelectorAll(a)];
|
|
68
|
+
}
|
|
69
|
+
get #i() {
|
|
70
|
+
return this.#r.filter(c);
|
|
71
|
+
}
|
|
72
|
+
#a(e, t) {
|
|
73
|
+
let n = this.#r.flatMap(l).filter((t) => e.some((e) => e !== t && e.contains(t)));
|
|
74
|
+
return e.flatMap((e) => [...e.querySelectorAll(t)]).filter((e) => !n.some((t) => t.contains(e)));
|
|
75
|
+
}
|
|
76
|
+
#o(e) {
|
|
77
|
+
e.toReversed().forEach((e) => {
|
|
78
|
+
f(e, "closed"), this.#s(e, !1);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
#s(e, t) {
|
|
82
|
+
let n = t ? "open" : "closed";
|
|
83
|
+
e.ariaExpanded = t.toString(), !t && e === this.#n && (this.#n = null), l(e).forEach((i) => {
|
|
84
|
+
r(i, n, e), t ? (this.#t.set(i, i.hidden || !0), i.hidden = !1) : (i.matches(":focus-within") && e.focus(), i.hidden = this.#t.get(i) ?? !0);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
#c(e) {
|
|
88
|
+
return e.pointerType === "mouse" && this.hasAttribute("data-hover");
|
|
89
|
+
}
|
|
90
|
+
#l = (e) => {
|
|
91
|
+
let t = e.target.closest(a);
|
|
92
|
+
if (!t || !this.contains(t)) return;
|
|
93
|
+
let n = t === this.#n;
|
|
94
|
+
this.#n = null, n || this.toggle(t);
|
|
95
|
+
};
|
|
96
|
+
#u = (e) => {
|
|
97
|
+
let t = e.target.closest(a), n = e.relatedTarget;
|
|
98
|
+
this.#c(e) && t && this.contains(t) && !c(t) && !d(t, n) && (this.open(t), c(t) && (this.#n = t));
|
|
99
|
+
};
|
|
100
|
+
#d = (e) => {
|
|
101
|
+
if (!this.#c(e)) return;
|
|
102
|
+
let t = e.target, n = e.relatedTarget;
|
|
103
|
+
this.#i.filter((e) => d(e, t) && !d(e, n)).forEach((e) => this.close(e));
|
|
104
|
+
};
|
|
105
|
+
#f = (e) => {
|
|
106
|
+
this.contains(e.relatedTarget) || this.matches(":active") || this.close();
|
|
107
|
+
};
|
|
108
|
+
#p = (e) => {
|
|
109
|
+
let t = e.target, n = this.#r.find((e) => l(e).includes(t));
|
|
110
|
+
n && (this.open(n), t.hidden = "until-found");
|
|
111
|
+
};
|
|
112
|
+
#m = (e) => {
|
|
113
|
+
if (e.key !== "Escape" || e.defaultPrevented || !this.#i.length) return;
|
|
114
|
+
let t = e.target;
|
|
115
|
+
return this.contains(t) ? (e.preventDefault(), this.close(this.#i.findLast((e) => u(e, t)))) : this.close();
|
|
116
|
+
};
|
|
117
|
+
#h = (e) => {
|
|
118
|
+
if (this.dataset.arrowKeys === "false") return;
|
|
119
|
+
let t = e.target, n = this.#i.findLast((e) => u(e, t)), r = n ? this.#a(l(n), s) : this.#a([this], o), a = r.indexOf(t);
|
|
120
|
+
if (a !== -1) return e.key === "ArrowDown" && t instanceof HTMLButtonElement && c(t) ? (e.preventDefault(), this.#a(l(t), s)[0]?.focus()) : i(e, r, a);
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
customElements.get("cinq-disclosure-navigation") || customElements.define("cinq-disclosure-navigation", p);
|
|
124
|
+
//#endregion
|
|
125
|
+
export { p as DisclosureNavigation };
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type State = "open" | "closed";
|
|
2
|
+
/**
|
|
3
|
+
* Fires `beforetoggle` at `$element`, as a popover does: before the change,
|
|
4
|
+
* without bubbling, cancelable only when the element opens.
|
|
5
|
+
*
|
|
6
|
+
* @returns `false` if a listener canceled the opening.
|
|
7
|
+
*/
|
|
8
|
+
export declare const dispatchBeforetoggle: ($element: HTMLElement, newState: State, $source: HTMLButtonElement) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Fires `toggle` at `$element` in a later task, as a popover does. Changes made
|
|
11
|
+
* before it fires coalesce into one event, from the first old state to the last
|
|
12
|
+
* new state.
|
|
13
|
+
*/
|
|
14
|
+
export declare const queueToggle: ($element: HTMLElement, newState: State, $source: HTMLButtonElement) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Moves focus between `elements` with the arrow, Home and End keys, wrapping at
|
|
17
|
+
* both ends. Left and right follow the reading direction of the focused element.
|
|
18
|
+
* Keys pressed with a modifier are left to the browser.
|
|
19
|
+
*/
|
|
20
|
+
export declare const focusByKey: (event: KeyboardEvent, elements: HTMLElement[], index: number) => void;
|
|
21
|
+
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;AAUtC;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,GAC/B,UAAU,WAAW,EACrB,UAAU,KAAK,EACf,SAAS,iBAAiB,KACzB,OASF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,WAAW,GACtB,UAAU,WAAW,EACrB,UAAU,KAAK,EACf,SAAS,iBAAiB,KACzB,IAkBF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,UAAU,GACrB,OAAO,aAAa,EACpB,UAAU,WAAW,EAAE,EACvB,OAAO,MAAM,KACZ,IAyBF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agencecinq/disclosure-navigation",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Accessible WAI-ARIA disclosure navigation Web Component: submenus opened by buttons, on hover too.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"disclosure",
|
|
7
|
+
"navigation",
|
|
8
|
+
"nav",
|
|
9
|
+
"menu",
|
|
10
|
+
"web-component",
|
|
11
|
+
"accessibility",
|
|
12
|
+
"a11y",
|
|
13
|
+
"wai-aria",
|
|
14
|
+
"apg"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Jérémy Levron <jeremy@agencecinq.com> (https://agencecinq.com)",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/agencecinq/ui.git",
|
|
22
|
+
"directory": "packages/disclosure-navigation"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@vitest/browser-playwright": "^4.1.11",
|
|
38
|
+
"playwright": "^1.64.1",
|
|
39
|
+
"vite": "^8.2.0",
|
|
40
|
+
"vite-plugin-dts": "^5.0.3",
|
|
41
|
+
"vitest": "^4.1.11"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "vite build",
|
|
48
|
+
"dev": "vite build --watch",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest"
|
|
51
|
+
}
|
|
6
52
|
}
|