@y14e/accordion 1.4.13 → 1.4.15
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 +4 -4
- package/dist/index.bundle.cjs +292 -0
- package/dist/index.bundle.js +286 -0
- package/dist/index.cjs +16 -636
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -630
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -10,14 +10,14 @@ npm i @y14e/accordion
|
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
// npm
|
|
13
|
-
import Accordion from '@y14e/accordion@1.4.
|
|
13
|
+
import Accordion from '@y14e/accordion@1.4.15';
|
|
14
14
|
|
|
15
15
|
// CDNs
|
|
16
|
-
import Accordion from 'https://esm.sh/@y14e/accordion@1.4.
|
|
16
|
+
import Accordion from 'https://esm.sh/@y14e/accordion@1.4.15';
|
|
17
17
|
// or
|
|
18
|
-
import Accordion from 'https://cdn.jsdelivr.net/npm/@y14e/accordion@1.4.
|
|
18
|
+
import Accordion from 'https://cdn.jsdelivr.net/npm/@y14e/accordion@1.4.15/+esm';
|
|
19
19
|
// or
|
|
20
|
-
import Accordion from 'https://esm.unpkg.com/@y14e/accordion@1.4.
|
|
20
|
+
import Accordion from 'https://esm.unpkg.com/@y14e/accordion@1.4.15';
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Usage
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var attributesUtils = require('@y14e/attributes-utils');
|
|
4
|
+
var Button = require('@y14e/button');
|
|
5
|
+
var rovingTabindex = require('@y14e/roving-tabindex');
|
|
6
|
+
|
|
7
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
8
|
+
|
|
9
|
+
var Button__default = /*#__PURE__*/_interopDefault(Button);
|
|
10
|
+
|
|
11
|
+
// src/index.ts
|
|
12
|
+
var Accordion = class _Accordion {
|
|
13
|
+
static defaults = {};
|
|
14
|
+
#rootElement;
|
|
15
|
+
#defaults = {
|
|
16
|
+
animation: { duration: 300, easing: "ease" },
|
|
17
|
+
selector: {
|
|
18
|
+
content: ":has(> [data-accordion-trigger]) + *",
|
|
19
|
+
trigger: "[data-accordion-trigger]"
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
#settings;
|
|
23
|
+
#triggerElements;
|
|
24
|
+
#contentElements;
|
|
25
|
+
#bindings = /* @__PURE__ */ new WeakMap();
|
|
26
|
+
#eventController = null;
|
|
27
|
+
#animationController = null;
|
|
28
|
+
#cleanupRovingTabIndex = null;
|
|
29
|
+
#buttons = [];
|
|
30
|
+
#isDestroyed = false;
|
|
31
|
+
constructor(root, options = {}) {
|
|
32
|
+
if (!(root instanceof HTMLElement)) {
|
|
33
|
+
throw new TypeError("Invalid root element");
|
|
34
|
+
}
|
|
35
|
+
if (root.hasAttribute("data-accordion-initialized")) {
|
|
36
|
+
console.warn("Already initialized");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.#rootElement = root;
|
|
40
|
+
this.#defaults = this.#mergeOptions(this.#defaults, _Accordion.defaults);
|
|
41
|
+
this.#settings = this.#mergeOptions(this.#defaults, options);
|
|
42
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches && Object.assign(this.#settings.animation, { duration: 0 });
|
|
43
|
+
const { trigger, content } = this.#settings.selector;
|
|
44
|
+
const NOT_NESTED = `:not(:scope ${content} *)`;
|
|
45
|
+
this.#triggerElements = [
|
|
46
|
+
...this.#rootElement.querySelectorAll(
|
|
47
|
+
`${trigger}${NOT_NESTED}`
|
|
48
|
+
)
|
|
49
|
+
];
|
|
50
|
+
if (!this.#triggerElements.length) {
|
|
51
|
+
console.warn("Missing trigger elements");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.#contentElements = [
|
|
55
|
+
...this.#rootElement.querySelectorAll(
|
|
56
|
+
`${content}${NOT_NESTED}`
|
|
57
|
+
)
|
|
58
|
+
];
|
|
59
|
+
if (!this.#contentElements.length) {
|
|
60
|
+
console.warn("Missing content elements");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.#triggerElements.forEach((trigger2, i) => {
|
|
64
|
+
const content2 = this.#contentElements[i];
|
|
65
|
+
if (!content2) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const binding = createBinding(trigger2, content2);
|
|
69
|
+
this.#bindings.set(trigger2, binding);
|
|
70
|
+
this.#bindings.set(content2, binding);
|
|
71
|
+
});
|
|
72
|
+
this.#initialize();
|
|
73
|
+
}
|
|
74
|
+
close(trigger) {
|
|
75
|
+
if (this.#isDestroyed) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!(trigger instanceof HTMLElement) || !this.#bindings.has(trigger)) {
|
|
79
|
+
console.warn("Invalid trigger element");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.#toggle(trigger, false);
|
|
83
|
+
}
|
|
84
|
+
async destroy(force = false) {
|
|
85
|
+
if (this.#isDestroyed) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.#isDestroyed = true;
|
|
89
|
+
this.#eventController?.abort();
|
|
90
|
+
this.#eventController = null;
|
|
91
|
+
this.#cleanupRovingTabIndex?.();
|
|
92
|
+
this.#cleanupRovingTabIndex = null;
|
|
93
|
+
this.#buttons.forEach((button) => {
|
|
94
|
+
button.destroy();
|
|
95
|
+
});
|
|
96
|
+
this.#buttons.length = 0;
|
|
97
|
+
!force && await this.#waitAnimationsFinish();
|
|
98
|
+
this.#contentElements.forEach((content) => {
|
|
99
|
+
force && this.#bindings.get(content)?.animation?.finish();
|
|
100
|
+
this.#onAnimationFinish(content);
|
|
101
|
+
});
|
|
102
|
+
this.#animationController?.abort();
|
|
103
|
+
this.#animationController = null;
|
|
104
|
+
attributesUtils.restoreAttributes([...this.#triggerElements, ...this.#contentElements]);
|
|
105
|
+
this.#triggerElements.length = 0;
|
|
106
|
+
this.#contentElements.length = 0;
|
|
107
|
+
this.#rootElement.removeAttribute("data-accordion-initialized");
|
|
108
|
+
}
|
|
109
|
+
open(trigger) {
|
|
110
|
+
if (this.#isDestroyed) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!(trigger instanceof HTMLElement) || !this.#bindings.has(trigger)) {
|
|
114
|
+
console.warn("Invalid trigger element");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.#toggle(trigger, true);
|
|
118
|
+
}
|
|
119
|
+
#initialize() {
|
|
120
|
+
attributesUtils.saveAttributes(this.#triggerElements, [
|
|
121
|
+
"aria-controls",
|
|
122
|
+
"aria-disabled",
|
|
123
|
+
"id",
|
|
124
|
+
"style",
|
|
125
|
+
"tabindex"
|
|
126
|
+
]);
|
|
127
|
+
attributesUtils.saveAttributes(this.#contentElements, ["aria-labelledby", "id", "role"]);
|
|
128
|
+
this.#eventController = new AbortController();
|
|
129
|
+
const { signal } = this.#eventController;
|
|
130
|
+
this.#triggerElements.forEach((trigger2, i) => {
|
|
131
|
+
const id = Math.random().toString(36).slice(-8);
|
|
132
|
+
const content2 = this.#contentElements[i];
|
|
133
|
+
if (!content2) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
content2.id ||= `accordion-content-${id}`;
|
|
137
|
+
attributesUtils.addTokenToAttribute(trigger2, "aria-controls", content2.id);
|
|
138
|
+
trigger2.setAttribute(
|
|
139
|
+
"aria-expanded",
|
|
140
|
+
String(trigger2.ariaExpanded === "true")
|
|
141
|
+
);
|
|
142
|
+
trigger2.id ||= `accordion-trigger-${id}`;
|
|
143
|
+
if (!isFocusable(trigger2)) {
|
|
144
|
+
trigger2.setAttribute("aria-disabled", "true");
|
|
145
|
+
trigger2.setAttribute("tabindex", "-1");
|
|
146
|
+
trigger2.style.setProperty("pointer-events", "none");
|
|
147
|
+
}
|
|
148
|
+
trigger2.addEventListener("click", this.#onTriggerClick, { signal });
|
|
149
|
+
attributesUtils.addTokenToAttribute(content2, "aria-labelledby", trigger2.id);
|
|
150
|
+
content2.setAttribute("role", "region");
|
|
151
|
+
content2.addEventListener("beforematch", this.#onContentBeforeMatch, {
|
|
152
|
+
signal
|
|
153
|
+
});
|
|
154
|
+
this.#buttons.push(new Button__default.default(trigger2));
|
|
155
|
+
});
|
|
156
|
+
const { trigger, content } = this.#settings.selector;
|
|
157
|
+
this.#cleanupRovingTabIndex = rovingTabindex.createRovingTabIndex(this.#rootElement, {
|
|
158
|
+
direction: "vertical",
|
|
159
|
+
navigationOnly: true,
|
|
160
|
+
selector: `${trigger}:not(:scope ${content} *)`,
|
|
161
|
+
wrap: true
|
|
162
|
+
});
|
|
163
|
+
this.#rootElement.setAttribute("data-accordion-initialized", "");
|
|
164
|
+
}
|
|
165
|
+
#onTriggerClick = (event) => {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
const trigger = event.currentTarget;
|
|
168
|
+
if (!(trigger instanceof HTMLElement)) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.#toggle(trigger, trigger.ariaExpanded === "false");
|
|
172
|
+
};
|
|
173
|
+
#onContentBeforeMatch = (event) => {
|
|
174
|
+
const content = event.currentTarget;
|
|
175
|
+
if (!(content instanceof HTMLElement)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const binding = this.#bindings.get(content);
|
|
179
|
+
if (!binding) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
binding.trigger.ariaExpanded === "false" && this.#toggle(binding.trigger, true, true);
|
|
183
|
+
};
|
|
184
|
+
#toggle(trigger, isOpen, isMatch = false) {
|
|
185
|
+
if (trigger.ariaExpanded === String(isOpen)) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const name = trigger.getAttribute("data-accordion-name");
|
|
189
|
+
if (name && isOpen) {
|
|
190
|
+
const opened = this.#triggerElements.find(
|
|
191
|
+
(t) => t !== trigger && t.getAttribute("data-accordion-name") === name && t.ariaExpanded === "true"
|
|
192
|
+
);
|
|
193
|
+
opened && this.#toggle(opened, false, isMatch);
|
|
194
|
+
}
|
|
195
|
+
trigger.setAttribute(
|
|
196
|
+
"aria-label",
|
|
197
|
+
trigger.getAttribute(
|
|
198
|
+
`data-accordion-${isOpen ? "expanded" : "collapsed"}-label`
|
|
199
|
+
) ?? trigger.ariaLabel ?? ""
|
|
200
|
+
);
|
|
201
|
+
const binding = this.#bindings.get(trigger);
|
|
202
|
+
if (!binding) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const { content } = binding;
|
|
206
|
+
const startSize = content.hidden ? 0 : content.offsetHeight;
|
|
207
|
+
if (content.hidden) {
|
|
208
|
+
content.hidden = false;
|
|
209
|
+
}
|
|
210
|
+
const endSize = isOpen ? content.scrollHeight : 0;
|
|
211
|
+
binding.animation?.cancel();
|
|
212
|
+
content.style.setProperty("overflow", "clip");
|
|
213
|
+
const { duration, easing } = this.#settings.animation;
|
|
214
|
+
const animation = content.animate(
|
|
215
|
+
{ blockSize: [`${startSize}px`, `${endSize}px`] },
|
|
216
|
+
{ duration: isMatch ? 0 : duration, easing }
|
|
217
|
+
);
|
|
218
|
+
binding.animation = animation;
|
|
219
|
+
trigger.setAttribute("aria-expanded", String(isOpen));
|
|
220
|
+
function cleanup() {
|
|
221
|
+
if (binding?.animation === animation) {
|
|
222
|
+
binding.animation = null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.#animationController = new AbortController();
|
|
226
|
+
const { signal } = this.#animationController;
|
|
227
|
+
animation.addEventListener("cancel", cleanup, { once: true, signal });
|
|
228
|
+
animation.addEventListener(
|
|
229
|
+
"finish",
|
|
230
|
+
() => {
|
|
231
|
+
if (binding?.animation === animation) {
|
|
232
|
+
this.#onAnimationFinish(content);
|
|
233
|
+
cleanup();
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
{ once: true, signal }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
#mergeOptions(target, source) {
|
|
240
|
+
return {
|
|
241
|
+
animation: { ...target.animation, ...source.animation ?? {} },
|
|
242
|
+
selector: { ...target.selector, ...source.selector ?? {} }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
#onAnimationFinish(content) {
|
|
246
|
+
const trigger = this.#bindings.get(content)?.trigger;
|
|
247
|
+
if (!trigger) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (trigger.ariaExpanded === "false") {
|
|
251
|
+
content.setAttribute("hidden", "until-found");
|
|
252
|
+
}
|
|
253
|
+
["block-size", "overflow"].forEach((name) => {
|
|
254
|
+
content.style.removeProperty(name);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async #waitAnimationsFinish() {
|
|
258
|
+
const promises = [];
|
|
259
|
+
this.#contentElements.forEach((content) => {
|
|
260
|
+
const animation = this.#bindings.get(content)?.animation;
|
|
261
|
+
animation && promises.push(waitAnimationFinish(animation));
|
|
262
|
+
});
|
|
263
|
+
await Promise.allSettled(promises);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function createBinding(trigger, content) {
|
|
267
|
+
return { trigger, content, animation: null };
|
|
268
|
+
}
|
|
269
|
+
function isFocusable(element) {
|
|
270
|
+
return !element.hasAttribute("disabled") && element.tabIndex >= 0;
|
|
271
|
+
}
|
|
272
|
+
function waitAnimationFinish(animation) {
|
|
273
|
+
if (["idle", "finished"].includes(animation.playState)) {
|
|
274
|
+
return Promise.resolve();
|
|
275
|
+
} else {
|
|
276
|
+
return new Promise(
|
|
277
|
+
(resolve) => animation.addEventListener("finish", () => resolve(), { once: true })
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Accordion
|
|
283
|
+
* WAI-ARIA compliant accordion pattern implementation in TypeScript.
|
|
284
|
+
*
|
|
285
|
+
* @version 1.4.15
|
|
286
|
+
* @author Yusuke Kamiyamane
|
|
287
|
+
* @license MIT
|
|
288
|
+
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
289
|
+
* @see {@link https://github.com/y14e/accordion}
|
|
290
|
+
*/
|
|
291
|
+
|
|
292
|
+
module.exports = Accordion;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { restoreAttributes, saveAttributes, addTokenToAttribute } from '@y14e/attributes-utils';
|
|
2
|
+
import Button from '@y14e/button';
|
|
3
|
+
import { createRovingTabIndex } from '@y14e/roving-tabindex';
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
var Accordion = class _Accordion {
|
|
7
|
+
static defaults = {};
|
|
8
|
+
#rootElement;
|
|
9
|
+
#defaults = {
|
|
10
|
+
animation: { duration: 300, easing: "ease" },
|
|
11
|
+
selector: {
|
|
12
|
+
content: ":has(> [data-accordion-trigger]) + *",
|
|
13
|
+
trigger: "[data-accordion-trigger]"
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
#settings;
|
|
17
|
+
#triggerElements;
|
|
18
|
+
#contentElements;
|
|
19
|
+
#bindings = /* @__PURE__ */ new WeakMap();
|
|
20
|
+
#eventController = null;
|
|
21
|
+
#animationController = null;
|
|
22
|
+
#cleanupRovingTabIndex = null;
|
|
23
|
+
#buttons = [];
|
|
24
|
+
#isDestroyed = false;
|
|
25
|
+
constructor(root, options = {}) {
|
|
26
|
+
if (!(root instanceof HTMLElement)) {
|
|
27
|
+
throw new TypeError("Invalid root element");
|
|
28
|
+
}
|
|
29
|
+
if (root.hasAttribute("data-accordion-initialized")) {
|
|
30
|
+
console.warn("Already initialized");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.#rootElement = root;
|
|
34
|
+
this.#defaults = this.#mergeOptions(this.#defaults, _Accordion.defaults);
|
|
35
|
+
this.#settings = this.#mergeOptions(this.#defaults, options);
|
|
36
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches && Object.assign(this.#settings.animation, { duration: 0 });
|
|
37
|
+
const { trigger, content } = this.#settings.selector;
|
|
38
|
+
const NOT_NESTED = `:not(:scope ${content} *)`;
|
|
39
|
+
this.#triggerElements = [
|
|
40
|
+
...this.#rootElement.querySelectorAll(
|
|
41
|
+
`${trigger}${NOT_NESTED}`
|
|
42
|
+
)
|
|
43
|
+
];
|
|
44
|
+
if (!this.#triggerElements.length) {
|
|
45
|
+
console.warn("Missing trigger elements");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.#contentElements = [
|
|
49
|
+
...this.#rootElement.querySelectorAll(
|
|
50
|
+
`${content}${NOT_NESTED}`
|
|
51
|
+
)
|
|
52
|
+
];
|
|
53
|
+
if (!this.#contentElements.length) {
|
|
54
|
+
console.warn("Missing content elements");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.#triggerElements.forEach((trigger2, i) => {
|
|
58
|
+
const content2 = this.#contentElements[i];
|
|
59
|
+
if (!content2) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const binding = createBinding(trigger2, content2);
|
|
63
|
+
this.#bindings.set(trigger2, binding);
|
|
64
|
+
this.#bindings.set(content2, binding);
|
|
65
|
+
});
|
|
66
|
+
this.#initialize();
|
|
67
|
+
}
|
|
68
|
+
close(trigger) {
|
|
69
|
+
if (this.#isDestroyed) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!(trigger instanceof HTMLElement) || !this.#bindings.has(trigger)) {
|
|
73
|
+
console.warn("Invalid trigger element");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.#toggle(trigger, false);
|
|
77
|
+
}
|
|
78
|
+
async destroy(force = false) {
|
|
79
|
+
if (this.#isDestroyed) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.#isDestroyed = true;
|
|
83
|
+
this.#eventController?.abort();
|
|
84
|
+
this.#eventController = null;
|
|
85
|
+
this.#cleanupRovingTabIndex?.();
|
|
86
|
+
this.#cleanupRovingTabIndex = null;
|
|
87
|
+
this.#buttons.forEach((button) => {
|
|
88
|
+
button.destroy();
|
|
89
|
+
});
|
|
90
|
+
this.#buttons.length = 0;
|
|
91
|
+
!force && await this.#waitAnimationsFinish();
|
|
92
|
+
this.#contentElements.forEach((content) => {
|
|
93
|
+
force && this.#bindings.get(content)?.animation?.finish();
|
|
94
|
+
this.#onAnimationFinish(content);
|
|
95
|
+
});
|
|
96
|
+
this.#animationController?.abort();
|
|
97
|
+
this.#animationController = null;
|
|
98
|
+
restoreAttributes([...this.#triggerElements, ...this.#contentElements]);
|
|
99
|
+
this.#triggerElements.length = 0;
|
|
100
|
+
this.#contentElements.length = 0;
|
|
101
|
+
this.#rootElement.removeAttribute("data-accordion-initialized");
|
|
102
|
+
}
|
|
103
|
+
open(trigger) {
|
|
104
|
+
if (this.#isDestroyed) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!(trigger instanceof HTMLElement) || !this.#bindings.has(trigger)) {
|
|
108
|
+
console.warn("Invalid trigger element");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.#toggle(trigger, true);
|
|
112
|
+
}
|
|
113
|
+
#initialize() {
|
|
114
|
+
saveAttributes(this.#triggerElements, [
|
|
115
|
+
"aria-controls",
|
|
116
|
+
"aria-disabled",
|
|
117
|
+
"id",
|
|
118
|
+
"style",
|
|
119
|
+
"tabindex"
|
|
120
|
+
]);
|
|
121
|
+
saveAttributes(this.#contentElements, ["aria-labelledby", "id", "role"]);
|
|
122
|
+
this.#eventController = new AbortController();
|
|
123
|
+
const { signal } = this.#eventController;
|
|
124
|
+
this.#triggerElements.forEach((trigger2, i) => {
|
|
125
|
+
const id = Math.random().toString(36).slice(-8);
|
|
126
|
+
const content2 = this.#contentElements[i];
|
|
127
|
+
if (!content2) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
content2.id ||= `accordion-content-${id}`;
|
|
131
|
+
addTokenToAttribute(trigger2, "aria-controls", content2.id);
|
|
132
|
+
trigger2.setAttribute(
|
|
133
|
+
"aria-expanded",
|
|
134
|
+
String(trigger2.ariaExpanded === "true")
|
|
135
|
+
);
|
|
136
|
+
trigger2.id ||= `accordion-trigger-${id}`;
|
|
137
|
+
if (!isFocusable(trigger2)) {
|
|
138
|
+
trigger2.setAttribute("aria-disabled", "true");
|
|
139
|
+
trigger2.setAttribute("tabindex", "-1");
|
|
140
|
+
trigger2.style.setProperty("pointer-events", "none");
|
|
141
|
+
}
|
|
142
|
+
trigger2.addEventListener("click", this.#onTriggerClick, { signal });
|
|
143
|
+
addTokenToAttribute(content2, "aria-labelledby", trigger2.id);
|
|
144
|
+
content2.setAttribute("role", "region");
|
|
145
|
+
content2.addEventListener("beforematch", this.#onContentBeforeMatch, {
|
|
146
|
+
signal
|
|
147
|
+
});
|
|
148
|
+
this.#buttons.push(new Button(trigger2));
|
|
149
|
+
});
|
|
150
|
+
const { trigger, content } = this.#settings.selector;
|
|
151
|
+
this.#cleanupRovingTabIndex = createRovingTabIndex(this.#rootElement, {
|
|
152
|
+
direction: "vertical",
|
|
153
|
+
navigationOnly: true,
|
|
154
|
+
selector: `${trigger}:not(:scope ${content} *)`,
|
|
155
|
+
wrap: true
|
|
156
|
+
});
|
|
157
|
+
this.#rootElement.setAttribute("data-accordion-initialized", "");
|
|
158
|
+
}
|
|
159
|
+
#onTriggerClick = (event) => {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
const trigger = event.currentTarget;
|
|
162
|
+
if (!(trigger instanceof HTMLElement)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.#toggle(trigger, trigger.ariaExpanded === "false");
|
|
166
|
+
};
|
|
167
|
+
#onContentBeforeMatch = (event) => {
|
|
168
|
+
const content = event.currentTarget;
|
|
169
|
+
if (!(content instanceof HTMLElement)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const binding = this.#bindings.get(content);
|
|
173
|
+
if (!binding) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
binding.trigger.ariaExpanded === "false" && this.#toggle(binding.trigger, true, true);
|
|
177
|
+
};
|
|
178
|
+
#toggle(trigger, isOpen, isMatch = false) {
|
|
179
|
+
if (trigger.ariaExpanded === String(isOpen)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const name = trigger.getAttribute("data-accordion-name");
|
|
183
|
+
if (name && isOpen) {
|
|
184
|
+
const opened = this.#triggerElements.find(
|
|
185
|
+
(t) => t !== trigger && t.getAttribute("data-accordion-name") === name && t.ariaExpanded === "true"
|
|
186
|
+
);
|
|
187
|
+
opened && this.#toggle(opened, false, isMatch);
|
|
188
|
+
}
|
|
189
|
+
trigger.setAttribute(
|
|
190
|
+
"aria-label",
|
|
191
|
+
trigger.getAttribute(
|
|
192
|
+
`data-accordion-${isOpen ? "expanded" : "collapsed"}-label`
|
|
193
|
+
) ?? trigger.ariaLabel ?? ""
|
|
194
|
+
);
|
|
195
|
+
const binding = this.#bindings.get(trigger);
|
|
196
|
+
if (!binding) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const { content } = binding;
|
|
200
|
+
const startSize = content.hidden ? 0 : content.offsetHeight;
|
|
201
|
+
if (content.hidden) {
|
|
202
|
+
content.hidden = false;
|
|
203
|
+
}
|
|
204
|
+
const endSize = isOpen ? content.scrollHeight : 0;
|
|
205
|
+
binding.animation?.cancel();
|
|
206
|
+
content.style.setProperty("overflow", "clip");
|
|
207
|
+
const { duration, easing } = this.#settings.animation;
|
|
208
|
+
const animation = content.animate(
|
|
209
|
+
{ blockSize: [`${startSize}px`, `${endSize}px`] },
|
|
210
|
+
{ duration: isMatch ? 0 : duration, easing }
|
|
211
|
+
);
|
|
212
|
+
binding.animation = animation;
|
|
213
|
+
trigger.setAttribute("aria-expanded", String(isOpen));
|
|
214
|
+
function cleanup() {
|
|
215
|
+
if (binding?.animation === animation) {
|
|
216
|
+
binding.animation = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.#animationController = new AbortController();
|
|
220
|
+
const { signal } = this.#animationController;
|
|
221
|
+
animation.addEventListener("cancel", cleanup, { once: true, signal });
|
|
222
|
+
animation.addEventListener(
|
|
223
|
+
"finish",
|
|
224
|
+
() => {
|
|
225
|
+
if (binding?.animation === animation) {
|
|
226
|
+
this.#onAnimationFinish(content);
|
|
227
|
+
cleanup();
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{ once: true, signal }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
#mergeOptions(target, source) {
|
|
234
|
+
return {
|
|
235
|
+
animation: { ...target.animation, ...source.animation ?? {} },
|
|
236
|
+
selector: { ...target.selector, ...source.selector ?? {} }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
#onAnimationFinish(content) {
|
|
240
|
+
const trigger = this.#bindings.get(content)?.trigger;
|
|
241
|
+
if (!trigger) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (trigger.ariaExpanded === "false") {
|
|
245
|
+
content.setAttribute("hidden", "until-found");
|
|
246
|
+
}
|
|
247
|
+
["block-size", "overflow"].forEach((name) => {
|
|
248
|
+
content.style.removeProperty(name);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
async #waitAnimationsFinish() {
|
|
252
|
+
const promises = [];
|
|
253
|
+
this.#contentElements.forEach((content) => {
|
|
254
|
+
const animation = this.#bindings.get(content)?.animation;
|
|
255
|
+
animation && promises.push(waitAnimationFinish(animation));
|
|
256
|
+
});
|
|
257
|
+
await Promise.allSettled(promises);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
function createBinding(trigger, content) {
|
|
261
|
+
return { trigger, content, animation: null };
|
|
262
|
+
}
|
|
263
|
+
function isFocusable(element) {
|
|
264
|
+
return !element.hasAttribute("disabled") && element.tabIndex >= 0;
|
|
265
|
+
}
|
|
266
|
+
function waitAnimationFinish(animation) {
|
|
267
|
+
if (["idle", "finished"].includes(animation.playState)) {
|
|
268
|
+
return Promise.resolve();
|
|
269
|
+
} else {
|
|
270
|
+
return new Promise(
|
|
271
|
+
(resolve) => animation.addEventListener("finish", () => resolve(), { once: true })
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Accordion
|
|
277
|
+
* WAI-ARIA compliant accordion pattern implementation in TypeScript.
|
|
278
|
+
*
|
|
279
|
+
* @version 1.4.15
|
|
280
|
+
* @author Yusuke Kamiyamane
|
|
281
|
+
* @license MIT
|
|
282
|
+
* @copyright Copyright (c) Yusuke Kamiyamane
|
|
283
|
+
* @see {@link https://github.com/y14e/accordion}
|
|
284
|
+
*/
|
|
285
|
+
|
|
286
|
+
export { Accordion as default };
|