@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 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';
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.13';
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.13/+esm';
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.13';
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 };