@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.
@@ -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;#e;constructor(){super();const t=this;t.button=null,t.content=null,t.#t=e=>{e.preventDefault();const n="true"!==t.button.getAttribute("aria-expanded");t.button.setAttribute("aria-expanded",n),t.content.collapsed=!n}}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.type||="button",t.button.setAttribute("aria-controls",t.content.id),t.content.setAttribute("aria-labelledby",t.button.id);const e=t.content.hasAttribute("open");t.button.setAttribute("aria-expanded",e),t.content.collapsed=!e,t.#e=new AbortController,t.button.addEventListener("click",t.#t,{signal:t.#e.signal})}disconnectedCallback(){const t=this;t.#e&&(t.#e.abort(),t.#e=null)}}class CollapsibleContent extends HTMLElement{#n;#e;#o=!1;constructor(){super();const t=this;t.#n=e=>{"height"===e.propertyName&&(t.#o=!1,t.collapsed||(t.style.height="auto"))}}connectedCallback(){const t=this;t.style.height=t.hasAttribute("open")?"auto":"0",t.#e=new AbortController,t.addEventListener("transitionend",t.#n,{signal:t.#e.signal})}disconnectedCallback(){const t=this;t.#e&&(t.#e.abort(),t.#e=null)}set collapsed(t){const e=this;if(e.#o)return;const n="0s"!==getComputedStyle(e).transitionDuration;t?(n&&(e.#o=!0),e.style.height=`${e.scrollHeight}px`,requestAnimationFrame((()=>{requestAnimationFrame((()=>{e.style.height="0px"}))})),e.removeAttribute("open"),e.setAttribute("aria-hidden","true"),e.setAttribute("inert","")):("auto"!==e.style.height&&(n&&(e.#o=!0),e.style.height=`${e.scrollHeight}px`),e.setAttribute("open",""),e.removeAttribute("aria-hidden"),e.removeAttribute("inert"))}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}));
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.0.0",
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.4.4",
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
- display: block;
2
+ display: block;
3
3
  }
4
4
 
5
5
  collapsible-component button {
6
- width: 100%;
7
- text-align: left;
8
- background: none;
9
- border: none;
10
- cursor: pointer;
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
- outline: 2px solid currentColor;
15
- outline-offset: 2px;
14
+ outline: 2px solid currentColor;
15
+ outline-offset: 2px;
16
16
  }
17
17
 
18
18
  collapsible-content {
19
- --collapsible-duration: 0.35s;
20
- --collapsible-easing: ease-out;
19
+ --collapsible-duration: 0.35s;
20
+ --collapsible-easing: ease;
21
21
 
22
- display: block;
23
- overflow: hidden;
24
- transition: height var(--collapsible-duration) var(--collapsible-easing);
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
- collapsible-content {
29
- transition: none;
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 with proper this binding
22
- _.#handleClick = (e) => {
23
- e.preventDefault();
24
- // toggle expanded value
25
- const expanded = _.button.getAttribute('aria-expanded') !== 'true';
26
- _.button.setAttribute('aria-expanded', expanded);
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 ||= 'button';
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 based on open attribute
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.collapsed = !open;
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
- // exit if isn't height property
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
- // prevent rapid clicking during animation
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 (value) {
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
- } else {
178
- // only animate if not already auto
179
- if (_.style.height !== 'auto') {
180
- if (hasTransition) {
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