@magic-spells/collapsible-content 1.0.0 → 1.1.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 +72 -21
- package/dist/collapsible-content.cjs.js +128 -36
- package/dist/collapsible-content.cjs.js.map +1 -1
- package/dist/collapsible-content.css +16 -16
- package/dist/collapsible-content.esm.js +128 -36
- package/dist/collapsible-content.esm.js.map +1 -1
- package/dist/collapsible-content.js +128 -36
- package/dist/collapsible-content.js.map +1 -1
- package/dist/collapsible-content.min.css +1 -1
- package/dist/collapsible-content.min.js +1 -1
- package/package.json +2 -2
- package/src/collapsible-content.css +16 -16
- package/src/collapsible-content.js +128 -36
|
@@ -1 +1 @@
|
|
|
1
|
-
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).CollapsibleContent={})}(this,(function(t){"use strict";class CollapsibleComponent extends HTMLElement{#t
|
|
1
|
+
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).CollapsibleContent={})}(this,(function(t){"use strict";class CollapsibleComponent extends HTMLElement{static#t=new Map;static#e(t,e){CollapsibleComponent.#t.has(t)||CollapsibleComponent.#t.set(t,new Set),CollapsibleComponent.#t.get(t).add(e)}static#o(t,e){const o=CollapsibleComponent.#t.get(t);o&&(o.delete(e),0===o.size&&CollapsibleComponent.#t.delete(t))}static get observedAttributes(){return["group"]}#n;#l;constructor(){super();const t=this;t.button=null,t.content=null,t.#n=()=>{const e=t.content.collapsed;t.content.collapsed=!e,t.button.setAttribute("aria-expanded",!t.content.collapsed),e&&t.#r()}}connectedCallback(){const t=this;if(t.button=t.querySelector("button"),t.content=t.querySelector("collapsible-content"),!t.button||!t.content){const e=new Error("CollapsibleComponent requires a <button> and a <collapsible-content>.");return console.error(e.message),void t.dispatchEvent(new CustomEvent("collapsible-error",{bubbles:!0,detail:{error:e}}))}t.button.id||=`collapsible-button-${crypto.randomUUID().slice(0,8)}`,t.content.id||=`collapsible-content-${crypto.randomUUID().slice(0,8)}`,t.button.hasAttribute("type")||(t.button.type="button"),t.button.setAttribute("aria-controls",t.content.id),t.content.setAttribute("aria-labelledby",t.button.id),t.content.hasAttribute("role")||t.content.setAttribute("role","region");const e=t.content.hasAttribute("open");t.button.setAttribute("aria-expanded",e),t.content.style.height=e?"auto":"0px",e?(t.content.removeAttribute("aria-hidden"),t.content.removeAttribute("inert")):(t.content.setAttribute("aria-hidden","true"),t.content.setAttribute("inert","")),t.#l=new AbortController,t.button.addEventListener("click",t.#n,{signal:t.#l.signal})}attributeChangedCallback(t,e,o){"group"===t&&(e&&CollapsibleComponent.#o(e,this),o&&CollapsibleComponent.#e(o,this))}disconnectedCallback(){const t=this,e=t.getAttribute("group");e&&CollapsibleComponent.#o(e,t),t.#l&&(t.#l.abort(),t.#l=null)}#r(){const t=this,e=t.getAttribute("group");if(!e)return;const o=CollapsibleComponent.#t.get(e);if(o)for(const e of o)e!==t&&(e.content.collapsed||(e.content.collapsed=!0,e.button.setAttribute("aria-expanded","false")))}}class CollapsibleContent extends HTMLElement{#i;#l;#s=!1;constructor(){super();const t=this;t.#i=e=>{e.target===t&&"height"===e.propertyName&&(t.#s=!1,t.style.removeProperty("--collapsible-duration"),t.collapsed||(t.style.height="auto"))}}connectedCallback(){const t=this;t.style.height=t.hasAttribute("open")?"auto":"0",t.#l=new AbortController,t.addEventListener("transitionend",t.#i,{signal:t.#l.signal})}disconnectedCallback(){const t=this;t.#s=!1,t.#l&&(t.#l.abort(),t.#l=null)}get#a(){const t=this.getAttribute("speed");if(null===t)return 900;const e=Number(t);return e>0?e:900}get#c(){const t=this.getAttribute("min-duration");if(null===t)return.25;const e=Number(t);return e>0?e:.25}get#u(){const t=this.getAttribute("max-duration");if(null===t)return 1;const e=Number(t);return e>0?e:1}#b(t,e){const o=this,n=Math.abs(e-t),l=Math.min(o.#u,Math.max(o.#c,n/o.#a));o.style.setProperty("--collapsible-duration",`${l.toFixed(3)}s`)}set collapsed(t){const e=this,o=Boolean(t);if(e.collapsed===o)return;const n="0s"!==getComputedStyle(e).transitionDuration;if(o){if(e.removeAttribute("open"),e.setAttribute("aria-hidden","true"),e.setAttribute("inert",""),!n)return void(e.style.height="0px");const t=e.getBoundingClientRect().height;e.style.height=`${t}px`,e.#b(t,0),e.#s=!0,e.offsetHeight,e.style.height="0px"}else{if(e.setAttribute("open",""),e.removeAttribute("aria-hidden"),e.removeAttribute("inert"),!n)return void(e.style.height="auto");const t=e.getBoundingClientRect().height;e.style.height=`${t}px`,e.#b(t,e.scrollHeight),e.#s=!0,e.offsetHeight,e.style.height=`${e.scrollHeight}px`}}get collapsed(){return!this.hasAttribute("open")}}customElements.get("collapsible-content")||customElements.define("collapsible-content",CollapsibleContent),customElements.get("collapsible-component")||customElements.define("collapsible-component",CollapsibleComponent),t.CollapsibleComponent=CollapsibleComponent,t.CollapsibleContent=CollapsibleContent}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magic-spells/collapsible-content",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Collapsible content web component",
|
|
5
5
|
"author": "Cory Schulz",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@eslint/js": "^9.38.0",
|
|
61
61
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
62
|
-
"@rollup/plugin-terser": "^0.
|
|
62
|
+
"@rollup/plugin-terser": "^1.0.0",
|
|
63
63
|
"eslint": "^9.38.0",
|
|
64
64
|
"globals": "^15.15.0",
|
|
65
65
|
"postcss-lightningcss": "^1.0.1",
|
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
collapsible-component {
|
|
2
|
-
|
|
2
|
+
display: block;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
5
|
collapsible-component button {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
width: 100%;
|
|
7
|
+
text-align: left;
|
|
8
|
+
background: none;
|
|
9
|
+
border: none;
|
|
10
|
+
cursor: pointer;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
collapsible-component button:focus-visible {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
outline: 2px solid currentColor;
|
|
15
|
+
outline-offset: 2px;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
collapsible-content {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
--collapsible-duration: 0.35s;
|
|
20
|
+
--collapsible-easing: ease;
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
display: block;
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
transition: height var(--collapsible-duration) var(--collapsible-easing);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
@media (prefers-reduced-motion: reduce) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
collapsible-content {
|
|
29
|
+
transition: none;
|
|
30
|
+
}
|
|
31
31
|
}
|
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import './collapsible-content.css';
|
|
2
2
|
|
|
3
|
+
const DEFAULT_SPEED = 900; // px per second
|
|
4
|
+
const MIN_DURATION = 0.25; // seconds
|
|
5
|
+
const MAX_DURATION = 1.0; // seconds
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* Custom element that creates a collapsible/expandable component with proper accessibility
|
|
5
9
|
*/
|
|
6
10
|
class CollapsibleComponent extends HTMLElement {
|
|
11
|
+
static #groups = new Map();
|
|
12
|
+
|
|
13
|
+
static #addToGroup(name, instance) {
|
|
14
|
+
if (!CollapsibleComponent.#groups.has(name)) {
|
|
15
|
+
CollapsibleComponent.#groups.set(name, new Set());
|
|
16
|
+
}
|
|
17
|
+
CollapsibleComponent.#groups.get(name).add(instance);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static #removeFromGroup(name, instance) {
|
|
21
|
+
const group = CollapsibleComponent.#groups.get(name);
|
|
22
|
+
if (group) {
|
|
23
|
+
group.delete(instance);
|
|
24
|
+
if (group.size === 0) CollapsibleComponent.#groups.delete(name);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static get observedAttributes() {
|
|
29
|
+
return ['group'];
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
#handleClick;
|
|
8
33
|
#abortController;
|
|
9
34
|
|
|
@@ -18,13 +43,12 @@ class CollapsibleComponent extends HTMLElement {
|
|
|
18
43
|
_.button = null;
|
|
19
44
|
_.content = null;
|
|
20
45
|
|
|
21
|
-
// define click handler
|
|
22
|
-
_.#handleClick = (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
_
|
|
27
|
-
_.content.collapsed = !expanded;
|
|
46
|
+
// define click handler — content is source of truth
|
|
47
|
+
_.#handleClick = () => {
|
|
48
|
+
const wasCollapsed = _.content.collapsed;
|
|
49
|
+
_.content.collapsed = !wasCollapsed;
|
|
50
|
+
_.button.setAttribute('aria-expanded', !_.content.collapsed);
|
|
51
|
+
if (wasCollapsed) _.#closeSiblings();
|
|
28
52
|
};
|
|
29
53
|
}
|
|
30
54
|
|
|
@@ -58,14 +82,26 @@ class CollapsibleComponent extends HTMLElement {
|
|
|
58
82
|
_.content.id ||= `collapsible-content-${crypto.randomUUID().slice(0, 8)}`;
|
|
59
83
|
|
|
60
84
|
// set accessibility attributes
|
|
61
|
-
_.button.type
|
|
85
|
+
if (!_.button.hasAttribute('type')) {
|
|
86
|
+
_.button.type = 'button';
|
|
87
|
+
}
|
|
62
88
|
_.button.setAttribute('aria-controls', _.content.id);
|
|
63
89
|
_.content.setAttribute('aria-labelledby', _.button.id);
|
|
90
|
+
if (!_.content.hasAttribute('role')) {
|
|
91
|
+
_.content.setAttribute('role', 'region');
|
|
92
|
+
}
|
|
64
93
|
|
|
65
|
-
// set initial state
|
|
94
|
+
// set initial state without triggering an opening/closing animation
|
|
66
95
|
const open = _.content.hasAttribute('open');
|
|
67
96
|
_.button.setAttribute('aria-expanded', open);
|
|
68
|
-
_.content.
|
|
97
|
+
_.content.style.height = open ? 'auto' : '0px';
|
|
98
|
+
if (open) {
|
|
99
|
+
_.content.removeAttribute('aria-hidden');
|
|
100
|
+
_.content.removeAttribute('inert');
|
|
101
|
+
} else {
|
|
102
|
+
_.content.setAttribute('aria-hidden', 'true');
|
|
103
|
+
_.content.setAttribute('inert', '');
|
|
104
|
+
}
|
|
69
105
|
|
|
70
106
|
// use AbortController to prevent duplicate listeners on reconnection
|
|
71
107
|
_.#abortController = new AbortController();
|
|
@@ -74,17 +110,40 @@ class CollapsibleComponent extends HTMLElement {
|
|
|
74
110
|
});
|
|
75
111
|
}
|
|
76
112
|
|
|
113
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
114
|
+
if (name !== 'group') return;
|
|
115
|
+
if (oldValue) CollapsibleComponent.#removeFromGroup(oldValue, this);
|
|
116
|
+
if (newValue) CollapsibleComponent.#addToGroup(newValue, this);
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
/**
|
|
78
120
|
* Called when element is removed from the DOM
|
|
79
121
|
* Cleans up event listeners
|
|
80
122
|
*/
|
|
81
123
|
disconnectedCallback() {
|
|
82
124
|
const _ = this;
|
|
125
|
+
const group = _.getAttribute('group');
|
|
126
|
+
if (group) CollapsibleComponent.#removeFromGroup(group, _);
|
|
83
127
|
if (_.#abortController) {
|
|
84
128
|
_.#abortController.abort();
|
|
85
129
|
_.#abortController = null;
|
|
86
130
|
}
|
|
87
131
|
}
|
|
132
|
+
|
|
133
|
+
#closeSiblings() {
|
|
134
|
+
const _ = this;
|
|
135
|
+
const groupName = _.getAttribute('group');
|
|
136
|
+
if (!groupName) return;
|
|
137
|
+
const group = CollapsibleComponent.#groups.get(groupName);
|
|
138
|
+
if (!group) return;
|
|
139
|
+
for (const sibling of group) {
|
|
140
|
+
if (sibling === _) continue;
|
|
141
|
+
if (!sibling.content.collapsed) {
|
|
142
|
+
sibling.content.collapsed = true;
|
|
143
|
+
sibling.button.setAttribute('aria-expanded', 'false');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
/**
|
|
@@ -104,10 +163,11 @@ class CollapsibleContent extends HTMLElement {
|
|
|
104
163
|
|
|
105
164
|
// define event handler using arrow function for proper binding
|
|
106
165
|
_.#handleTransitionEnd = (event) => {
|
|
107
|
-
|
|
166
|
+
if (event.target !== _) return;
|
|
108
167
|
if (event.propertyName !== 'height') return;
|
|
109
168
|
|
|
110
169
|
_.#animating = false;
|
|
170
|
+
_.style.removeProperty('--collapsible-duration');
|
|
111
171
|
|
|
112
172
|
// remove the inline height to allow dynamic content changes
|
|
113
173
|
if (!_.collapsed) {
|
|
@@ -137,55 +197,87 @@ class CollapsibleContent extends HTMLElement {
|
|
|
137
197
|
*/
|
|
138
198
|
disconnectedCallback() {
|
|
139
199
|
const _ = this;
|
|
200
|
+
_.#animating = false;
|
|
140
201
|
if (_.#abortController) {
|
|
141
202
|
_.#abortController.abort();
|
|
142
203
|
_.#abortController = null;
|
|
143
204
|
}
|
|
144
205
|
}
|
|
145
206
|
|
|
207
|
+
get #speed() {
|
|
208
|
+
const attr = this.getAttribute('speed');
|
|
209
|
+
if (attr === null) return DEFAULT_SPEED;
|
|
210
|
+
const value = Number(attr);
|
|
211
|
+
return value > 0 ? value : DEFAULT_SPEED;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
get #minDuration() {
|
|
215
|
+
const attr = this.getAttribute('min-duration');
|
|
216
|
+
if (attr === null) return MIN_DURATION;
|
|
217
|
+
const value = Number(attr);
|
|
218
|
+
return value > 0 ? value : MIN_DURATION;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get #maxDuration() {
|
|
222
|
+
const attr = this.getAttribute('max-duration');
|
|
223
|
+
if (attr === null) return MAX_DURATION;
|
|
224
|
+
const value = Number(attr);
|
|
225
|
+
return value > 0 ? value : MAX_DURATION;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#setDynamicDuration(currentHeight, targetHeight) {
|
|
229
|
+
const _ = this;
|
|
230
|
+
const delta = Math.abs(targetHeight - currentHeight);
|
|
231
|
+
const duration = Math.min(_.#maxDuration, Math.max(_.#minDuration, delta / _.#speed));
|
|
232
|
+
_.style.setProperty('--collapsible-duration', `${duration.toFixed(3)}s`);
|
|
233
|
+
}
|
|
234
|
+
|
|
146
235
|
/**
|
|
147
236
|
* Handles setting the collapsed state with animation
|
|
148
237
|
* @param {boolean} value - Whether element should be collapsed
|
|
149
238
|
*/
|
|
150
239
|
set collapsed(value) {
|
|
151
240
|
const _ = this;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (_.#animating) return;
|
|
241
|
+
const collapsed = Boolean(value);
|
|
242
|
+
if (_.collapsed === collapsed) return;
|
|
155
243
|
|
|
156
244
|
// check if transitions are enabled (respects prefers-reduced-motion)
|
|
157
245
|
const hasTransition = getComputedStyle(_).transitionDuration !== '0s';
|
|
158
246
|
|
|
159
|
-
if (
|
|
160
|
-
if (hasTransition) {
|
|
161
|
-
_.#animating = true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// animate to closed
|
|
165
|
-
_.style.height = `${_.scrollHeight}px`;
|
|
166
|
-
|
|
167
|
-
// use requestAnimationFrame for reliable frame timing
|
|
168
|
-
requestAnimationFrame(() => {
|
|
169
|
-
requestAnimationFrame(() => {
|
|
170
|
-
_.style.height = '0px';
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
247
|
+
if (collapsed) {
|
|
174
248
|
_.removeAttribute('open');
|
|
175
249
|
_.setAttribute('aria-hidden', 'true');
|
|
176
250
|
_.setAttribute('inert', '');
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
_.#animating = true;
|
|
182
|
-
}
|
|
183
|
-
_.style.height = `${_.scrollHeight}px`;
|
|
251
|
+
|
|
252
|
+
if (!hasTransition) {
|
|
253
|
+
_.style.height = '0px';
|
|
254
|
+
return;
|
|
184
255
|
}
|
|
185
256
|
|
|
257
|
+
// capture current height in px (converts auto or mid-animation value)
|
|
258
|
+
const currentHeight = _.getBoundingClientRect().height;
|
|
259
|
+
_.style.height = `${currentHeight}px`;
|
|
260
|
+
_.#setDynamicDuration(currentHeight, 0);
|
|
261
|
+
_.#animating = true;
|
|
262
|
+
_.offsetHeight; // force reflow — browser commits the px start value
|
|
263
|
+
_.style.height = '0px';
|
|
264
|
+
} else {
|
|
186
265
|
_.setAttribute('open', '');
|
|
187
266
|
_.removeAttribute('aria-hidden');
|
|
188
267
|
_.removeAttribute('inert');
|
|
268
|
+
|
|
269
|
+
if (!hasTransition) {
|
|
270
|
+
_.style.height = 'auto';
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// capture current height in px (0px or mid-animation value)
|
|
275
|
+
const currentHeight = _.getBoundingClientRect().height;
|
|
276
|
+
_.style.height = `${currentHeight}px`;
|
|
277
|
+
_.#setDynamicDuration(currentHeight, _.scrollHeight);
|
|
278
|
+
_.#animating = true;
|
|
279
|
+
_.offsetHeight; // force reflow — browser commits the px start value
|
|
280
|
+
_.style.height = `${_.scrollHeight}px`;
|
|
189
281
|
}
|
|
190
282
|
}
|
|
191
283
|
|