@magic-spells/collapsible-content 1.1.0 → 1.2.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.
@@ -2,35 +2,34 @@ import './collapsible-content.css';
2
2
 
3
3
  const DEFAULT_SPEED = 900; // px per second
4
4
  const MIN_DURATION = 0.25; // seconds
5
- const MAX_DURATION = 1.0; // seconds
5
+ const MAX_DURATION = 0.8; // seconds
6
+
7
+ // hidden hook the content element uses to report a state change to its component
8
+ const NOTIFY = Symbol('collapsible:notify');
9
+
10
+ // named accordion groups — group attribute value -> Set of components
11
+ const namedGroups = new Map();
12
+
13
+ // <collapsible-group> element -> Set of components that joined it
14
+ const elementGroups = new WeakMap();
15
+
16
+ /**
17
+ * Optional ancestor element that links components without a shared group name.
18
+ * Add the `exclusive` attribute to make opening one item close the others.
19
+ */
20
+ class CollapsibleGroup extends HTMLElement {}
6
21
 
7
22
  /**
8
23
  * Custom element that creates a collapsible/expandable component with proper accessibility
9
24
  */
10
25
  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
26
  static get observedAttributes() {
29
27
  return ['group'];
30
28
  }
31
29
 
32
30
  #handleClick;
33
31
  #abortController;
32
+ #groupElement = null;
34
33
 
35
34
  /**
36
35
  * Initializes the component and sets up references to child elements
@@ -45,10 +44,7 @@ class CollapsibleComponent extends HTMLElement {
45
44
 
46
45
  // define click handler — content is source of truth
47
46
  _.#handleClick = () => {
48
- const wasCollapsed = _.content.collapsed;
49
- _.content.collapsed = !wasCollapsed;
50
- _.button.setAttribute('aria-expanded', !_.content.collapsed);
51
- if (wasCollapsed) _.#closeSiblings();
47
+ _.content.collapsed = !_.content.collapsed;
52
48
  };
53
49
  }
54
50
 
@@ -59,6 +55,13 @@ class CollapsibleComponent extends HTMLElement {
59
55
  connectedCallback() {
60
56
  const _ = this;
61
57
 
58
+ // (re)join a group on every connect — a DOM move runs disconnect then connect,
59
+ // and attributeChangedCallback only fires on a real attribute change.
60
+ // Both registries are Sets, so re-adding on first connect is a no-op.
61
+ const name = _.#groupName;
62
+ if (name) _.#addToNamedGroup(name);
63
+ else _.#joinGroupElement();
64
+
62
65
  // initialize element references once
63
66
  _.button = _.querySelector('button');
64
67
  _.content = _.querySelector('collapsible-content');
@@ -112,8 +115,16 @@ class CollapsibleComponent extends HTMLElement {
112
115
 
113
116
  attributeChangedCallback(name, oldValue, newValue) {
114
117
  if (name !== 'group') return;
115
- if (oldValue) CollapsibleComponent.#removeFromGroup(oldValue, this);
116
- if (newValue) CollapsibleComponent.#addToGroup(newValue, this);
118
+ const _ = this;
119
+
120
+ // an empty group="" counts as no name
121
+ if (oldValue) _.#removeFromNamedGroup(oldValue);
122
+ if (newValue) _.#addToNamedGroup(newValue);
123
+
124
+ // an explicit group name wins over any ancestor <collapsible-group>
125
+ if (!_.isConnected) return;
126
+ if (newValue) _.#leaveGroupElement();
127
+ else _.#joinGroupElement();
117
128
  }
118
129
 
119
130
  /**
@@ -122,26 +133,108 @@ class CollapsibleComponent extends HTMLElement {
122
133
  */
123
134
  disconnectedCallback() {
124
135
  const _ = this;
125
- const group = _.getAttribute('group');
126
- if (group) CollapsibleComponent.#removeFromGroup(group, _);
136
+ const name = _.#groupName;
137
+ if (name) _.#removeFromNamedGroup(name);
138
+ _.#leaveGroupElement();
127
139
  if (_.#abortController) {
128
140
  _.#abortController.abort();
129
141
  _.#abortController = null;
130
142
  }
131
143
  }
132
144
 
133
- #closeSiblings() {
145
+ /**
146
+ * Whether the panel is currently expanded
147
+ * @returns {boolean}
148
+ */
149
+ get open() {
150
+ return !!this.content && !this.content.collapsed;
151
+ }
152
+
153
+ set open(value) {
154
+ if (this.content) this.content.collapsed = !value;
155
+ }
156
+
157
+ /** Expands the panel (no-op if already expanded) */
158
+ show() {
159
+ this.open = true;
160
+ }
161
+
162
+ /** Collapses the panel (no-op if already collapsed) */
163
+ hide() {
164
+ this.open = false;
165
+ }
166
+
167
+ /** Toggles the panel */
168
+ toggle() {
169
+ this.open = !this.open;
170
+ }
171
+
172
+ /**
173
+ * Reports a state change coming from the content element
174
+ * @param {CollapsibleContent} content - the content element that changed
175
+ * @param {boolean} open - the new expanded state
176
+ */
177
+ [NOTIFY](content, open) {
134
178
  const _ = this;
135
- const groupName = _.getAttribute('group');
136
- if (!groupName) return;
137
- const group = CollapsibleComponent.#groups.get(groupName);
179
+ if (_.content !== content) return;
180
+
181
+ if (_.button) _.button.setAttribute('aria-expanded', String(open));
182
+ if (open) _.#closeSiblings();
183
+
184
+ _.dispatchEvent(
185
+ new CustomEvent('collapsible:toggle', {
186
+ bubbles: true,
187
+ composed: true,
188
+ detail: { open, content },
189
+ })
190
+ );
191
+ }
192
+
193
+ /** Group name, with an empty group="" normalized to null */
194
+ get #groupName() {
195
+ return this.getAttribute('group') || null;
196
+ }
197
+
198
+ #addToNamedGroup(name) {
199
+ if (!namedGroups.has(name)) namedGroups.set(name, new Set());
200
+ namedGroups.get(name).add(this);
201
+ }
202
+
203
+ #removeFromNamedGroup(name) {
204
+ const group = namedGroups.get(name);
138
205
  if (!group) return;
139
- for (const sibling of group) {
206
+ group.delete(this);
207
+ if (group.size === 0) namedGroups.delete(name);
208
+ }
209
+
210
+ #joinGroupElement() {
211
+ const _ = this;
212
+ const element = _.closest('collapsible-group');
213
+ if (!element) return;
214
+ if (!elementGroups.has(element)) elementGroups.set(element, new Set());
215
+ elementGroups.get(element).add(_);
216
+ _.#groupElement = element;
217
+ }
218
+
219
+ #leaveGroupElement() {
220
+ const _ = this;
221
+ if (!_.#groupElement) return;
222
+ elementGroups.get(_.#groupElement)?.delete(_);
223
+ _.#groupElement = null;
224
+ }
225
+
226
+ #closeSiblings() {
227
+ const _ = this;
228
+ const name = _.#groupName;
229
+ let siblings = null;
230
+ if (name) siblings = namedGroups.get(name);
231
+ else if (_.#groupElement?.hasAttribute('exclusive'))
232
+ siblings = elementGroups.get(_.#groupElement);
233
+ if (!siblings) return;
234
+
235
+ for (const sibling of siblings) {
140
236
  if (sibling === _) continue;
141
- if (!sibling.content.collapsed) {
142
- sibling.content.collapsed = true;
143
- sibling.button.setAttribute('aria-expanded', 'false');
144
- }
237
+ if (sibling.content && !sibling.content.collapsed) sibling.content.collapsed = true;
145
238
  }
146
239
  }
147
240
  }
@@ -150,9 +243,14 @@ class CollapsibleComponent extends HTMLElement {
150
243
  * Custom element that provides animated collapsible content
151
244
  */
152
245
  class CollapsibleContent extends HTMLElement {
246
+ static get observedAttributes() {
247
+ return ['open'];
248
+ }
249
+
153
250
  #handleTransitionEnd;
154
251
  #abortController;
155
- #animating = false;
252
+ #ready = false;
253
+ #selfWrite = false;
156
254
 
157
255
  /**
158
256
  * Initializes the content element and binds event handlers
@@ -166,7 +264,6 @@ class CollapsibleContent extends HTMLElement {
166
264
  if (event.target !== _) return;
167
265
  if (event.propertyName !== 'height') return;
168
266
 
169
- _.#animating = false;
170
267
  _.style.removeProperty('--collapsible-duration');
171
268
 
172
269
  // remove the inline height to allow dynamic content changes
@@ -189,6 +286,9 @@ class CollapsibleContent extends HTMLElement {
189
286
  _.addEventListener('transitionend', _.#handleTransitionEnd, {
190
287
  signal: _.#abortController.signal,
191
288
  });
289
+
290
+ // from here on, an external `open` attribute change animates
291
+ _.#ready = true;
192
292
  }
193
293
 
194
294
  /**
@@ -197,13 +297,24 @@ class CollapsibleContent extends HTMLElement {
197
297
  */
198
298
  disconnectedCallback() {
199
299
  const _ = this;
200
- _.#animating = false;
300
+ _.#ready = false;
201
301
  if (_.#abortController) {
202
302
  _.#abortController.abort();
203
303
  _.#abortController = null;
204
304
  }
205
305
  }
206
306
 
307
+ /**
308
+ * Runs the transition when `open` is added or removed from outside
309
+ */
310
+ attributeChangedCallback(name, oldValue, newValue) {
311
+ const _ = this;
312
+ if (name !== 'open' || _.#selfWrite || !_.#ready) return;
313
+ // only presence matters — ignore value-only changes (open -> open="x")
314
+ if ((oldValue === null) === (newValue === null)) return;
315
+ _.#transition(newValue === null);
316
+ }
317
+
207
318
  get #speed() {
208
319
  const attr = this.getAttribute('speed');
209
320
  if (attr === null) return DEFAULT_SPEED;
@@ -233,52 +344,64 @@ class CollapsibleContent extends HTMLElement {
233
344
  }
234
345
 
235
346
  /**
236
- * Handles setting the collapsed state with animation
237
- * @param {boolean} value - Whether element should be collapsed
347
+ * Animates to the requested state, then reports the change
348
+ * @param {boolean} collapsed - target state (the `open` attribute already matches)
238
349
  */
239
- set collapsed(value) {
350
+ #transition(collapsed) {
240
351
  const _ = this;
241
- const collapsed = Boolean(value);
242
- if (_.collapsed === collapsed) return;
243
352
 
244
353
  // check if transitions are enabled (respects prefers-reduced-motion)
245
354
  const hasTransition = getComputedStyle(_).transitionDuration !== '0s';
246
355
 
247
356
  if (collapsed) {
248
- _.removeAttribute('open');
249
357
  _.setAttribute('aria-hidden', 'true');
250
358
  _.setAttribute('inert', '');
251
359
 
252
360
  if (!hasTransition) {
253
361
  _.style.height = '0px';
254
- return;
362
+ } else {
363
+ // capture current height in px (converts auto or mid-animation value)
364
+ const currentHeight = _.getBoundingClientRect().height;
365
+ _.style.height = `${currentHeight}px`;
366
+ _.#setDynamicDuration(currentHeight, 0);
367
+ _.offsetHeight; // force reflow — browser commits the px start value
368
+ _.style.height = '0px';
255
369
  }
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
370
  } else {
265
- _.setAttribute('open', '');
266
371
  _.removeAttribute('aria-hidden');
267
372
  _.removeAttribute('inert');
268
373
 
269
374
  if (!hasTransition) {
270
375
  _.style.height = 'auto';
271
- return;
376
+ } else {
377
+ // capture current height in px (0px or mid-animation value)
378
+ const currentHeight = _.getBoundingClientRect().height;
379
+ _.style.height = `${currentHeight}px`;
380
+ _.#setDynamicDuration(currentHeight, _.scrollHeight);
381
+ _.offsetHeight; // force reflow — browser commits the px start value
382
+ _.style.height = `${_.scrollHeight}px`;
272
383
  }
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`;
281
384
  }
385
+
386
+ _.closest('collapsible-component')?.[NOTIFY]?.(_, !collapsed);
387
+ }
388
+
389
+ /**
390
+ * Handles setting the collapsed state with animation
391
+ * @param {boolean} value - Whether element should be collapsed
392
+ */
393
+ set collapsed(value) {
394
+ const _ = this;
395
+ const collapsed = Boolean(value);
396
+ if (_.collapsed === collapsed) return;
397
+
398
+ // mirror to the attribute without re-entering attributeChangedCallback
399
+ _.#selfWrite = true;
400
+ if (collapsed) _.removeAttribute('open');
401
+ else _.setAttribute('open', '');
402
+ _.#selfWrite = false;
403
+
404
+ _.#transition(collapsed);
282
405
  }
283
406
 
284
407
  /**
@@ -297,5 +420,8 @@ if (!customElements.get('collapsible-content')) {
297
420
  if (!customElements.get('collapsible-component')) {
298
421
  customElements.define('collapsible-component', CollapsibleComponent);
299
422
  }
423
+ if (!customElements.get('collapsible-group')) {
424
+ customElements.define('collapsible-group', CollapsibleGroup);
425
+ }
300
426
 
301
- export { CollapsibleContent, CollapsibleComponent };
427
+ export { CollapsibleContent, CollapsibleComponent, CollapsibleGroup };