@magic-spells/collapsible-content 0.2.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,10 +1,36 @@
1
- import './index.scss';
1
+ import './collapsible-content.css';
2
+
3
+ const DEFAULT_SPEED = 900; // px per second
4
+ const MIN_DURATION = 0.25; // seconds
5
+ const MAX_DURATION = 1.0; // seconds
2
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;
33
+ #abortController;
8
34
 
9
35
  /**
10
36
  * Initializes the component and sets up references to child elements
@@ -17,13 +43,12 @@ class CollapsibleComponent extends HTMLElement {
17
43
  _.button = null;
18
44
  _.content = null;
19
45
 
20
- // define click handler with proper this binding
21
- _.#handleClick = (e) => {
22
- e.preventDefault();
23
- // toggle expanded value
24
- const expanded = _.button.getAttribute('aria-expanded') !== 'true';
25
- _.button.setAttribute('aria-expanded', expanded);
26
- _.content.hidden = !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();
27
52
  };
28
53
  }
29
54
 
@@ -39,7 +64,16 @@ class CollapsibleComponent extends HTMLElement {
39
64
  _.content = _.querySelector('collapsible-content');
40
65
 
41
66
  if (!_.button || !_.content) {
42
- console.error('CollapsibleComponent requires a <button> and a <collapsible-content>.');
67
+ const error = new Error(
68
+ 'CollapsibleComponent requires a <button> and a <collapsible-content>.'
69
+ );
70
+ console.error(error.message);
71
+ _.dispatchEvent(
72
+ new CustomEvent('collapsible-error', {
73
+ bubbles: true,
74
+ detail: { error },
75
+ })
76
+ );
43
77
  return;
44
78
  }
45
79
 
@@ -48,18 +82,38 @@ class CollapsibleComponent extends HTMLElement {
48
82
  _.content.id ||= `collapsible-content-${crypto.randomUUID().slice(0, 8)}`;
49
83
 
50
84
  // set accessibility attributes
85
+ if (!_.button.hasAttribute('type')) {
86
+ _.button.type = 'button';
87
+ }
51
88
  _.button.setAttribute('aria-controls', _.content.id);
52
- _.content.setAttribute('role', 'region');
53
89
  _.content.setAttribute('aria-labelledby', _.button.id);
90
+ if (!_.content.hasAttribute('role')) {
91
+ _.content.setAttribute('role', 'region');
92
+ }
54
93
 
55
- // set initial state based on aria-expanded attribute
56
- const expanded = _.button.getAttribute('aria-expanded') === 'true';
94
+ // set initial state without triggering an opening/closing animation
95
+ const open = _.content.hasAttribute('open');
96
+ _.button.setAttribute('aria-expanded', 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
+ }
57
105
 
58
- // make sure content state matches button state
59
- _.content.hidden = !expanded;
106
+ // use AbortController to prevent duplicate listeners on reconnection
107
+ _.#abortController = new AbortController();
108
+ _.button.addEventListener('click', _.#handleClick, {
109
+ signal: _.#abortController.signal,
110
+ });
111
+ }
60
112
 
61
- // add event listener
62
- _.button.addEventListener('click', _.#handleClick);
113
+ attributeChangedCallback(name, oldValue, newValue) {
114
+ if (name !== 'group') return;
115
+ if (oldValue) CollapsibleComponent.#removeFromGroup(oldValue, this);
116
+ if (newValue) CollapsibleComponent.#addToGroup(newValue, this);
63
117
  }
64
118
 
65
119
  /**
@@ -68,8 +122,26 @@ class CollapsibleComponent extends HTMLElement {
68
122
  */
69
123
  disconnectedCallback() {
70
124
  const _ = this;
71
- if (_.button) {
72
- _.button.removeEventListener('click', _.#handleClick);
125
+ const group = _.getAttribute('group');
126
+ if (group) CollapsibleComponent.#removeFromGroup(group, _);
127
+ if (_.#abortController) {
128
+ _.#abortController.abort();
129
+ _.#abortController = null;
130
+ }
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
+ }
73
145
  }
74
146
  }
75
147
  }
@@ -79,6 +151,8 @@ class CollapsibleComponent extends HTMLElement {
79
151
  */
80
152
  class CollapsibleContent extends HTMLElement {
81
153
  #handleTransitionEnd;
154
+ #abortController;
155
+ #animating = false;
82
156
 
83
157
  /**
84
158
  * Initializes the content element and binds event handlers
@@ -89,36 +163,32 @@ class CollapsibleContent extends HTMLElement {
89
163
 
90
164
  // define event handler using arrow function for proper binding
91
165
  _.#handleTransitionEnd = (event) => {
92
- // exit if isn't height property
93
- if (event.propertyName != 'height') return;
166
+ if (event.target !== _) return;
167
+ if (event.propertyName !== 'height') return;
168
+
169
+ _.#animating = false;
170
+ _.style.removeProperty('--collapsible-duration');
94
171
 
95
172
  // remove the inline height to allow dynamic content changes
96
- if (!_.hidden) {
173
+ if (!_.collapsed) {
97
174
  _.style.height = 'auto';
98
175
  }
99
176
  };
100
-
101
- // add event listener to remove height after transition
102
- _.addEventListener('transitionend', _.#handleTransitionEnd);
103
177
  }
104
178
 
105
179
  /**
106
180
  * Called when element is added to the DOM
107
- * Sets initial height based on hidden state
181
+ * Sets initial height based on open attribute
108
182
  */
109
183
  connectedCallback() {
110
184
  const _ = this;
185
+ _.style.height = _.hasAttribute('open') ? 'auto' : '0';
111
186
 
112
- // get aria-expanded state from button
113
- const component = _.closest('collapsible-component');
114
- const button = component.querySelector('button');
115
- const expanded = button.getAttribute('aria-expanded') === 'true';
116
-
117
- if (expanded) {
118
- _.style.height = 'auto';
119
- } else {
120
- _.style.height = '0';
121
- }
187
+ // use AbortController to prevent duplicate listeners on reconnection
188
+ _.#abortController = new AbortController();
189
+ _.addEventListener('transitionend', _.#handleTransitionEnd, {
190
+ signal: _.#abortController.signal,
191
+ });
122
192
  }
123
193
 
124
194
  /**
@@ -127,56 +197,105 @@ class CollapsibleContent extends HTMLElement {
127
197
  */
128
198
  disconnectedCallback() {
129
199
  const _ = this;
130
- _.removeEventListener('transitionend', _.#handleTransitionEnd);
200
+ _.#animating = false;
201
+ if (_.#abortController) {
202
+ _.#abortController.abort();
203
+ _.#abortController = null;
204
+ }
205
+ }
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`);
131
233
  }
132
234
 
133
235
  /**
134
- * Handles setting the hidden attribute with animation
135
- * @param {boolean} value - Whether element should be hidden
236
+ * Handles setting the collapsed state with animation
237
+ * @param {boolean} value - Whether element should be collapsed
136
238
  */
137
- set hidden(value) {
239
+ set collapsed(value) {
138
240
  const _ = this;
241
+ const collapsed = Boolean(value);
242
+ if (_.collapsed === collapsed) return;
139
243
 
140
- // animate to closed
141
- if (value) {
142
- // reset height to animate from
143
- _.style.height = `${_.scrollHeight}px`;
144
-
145
- // wait one frame and animate to 0
146
- setTimeout(() => {
147
- _.style.height = '0px';
148
- }, 1);
244
+ // check if transitions are enabled (respects prefers-reduced-motion)
245
+ const hasTransition = getComputedStyle(_).transitionDuration !== '0s';
149
246
 
150
- // set accessibility attributes
247
+ if (collapsed) {
248
+ _.removeAttribute('open');
151
249
  _.setAttribute('aria-hidden', 'true');
152
250
  _.setAttribute('inert', '');
153
- _.setAttribute('hidden', '');
154
- } else {
155
- // only animate if not set to auto
156
- if (_.style.height !== 'auto') {
157
- // animate to open
158
- _.style.height = `${_.scrollHeight}px`;
251
+
252
+ if (!hasTransition) {
253
+ _.style.height = '0px';
254
+ return;
159
255
  }
160
256
 
161
- // remove accessibility attributes
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 {
265
+ _.setAttribute('open', '');
162
266
  _.removeAttribute('aria-hidden');
163
267
  _.removeAttribute('inert');
164
- _.removeAttribute('hidden');
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`;
165
281
  }
166
282
  }
167
283
 
168
284
  /**
169
- * Gets the hidden state from the attribute
170
- * @returns {boolean} Whether element is hidden
285
+ * Gets the collapsed state
286
+ * @returns {boolean} Whether element is collapsed
171
287
  */
172
- get hidden() {
173
- const _ = this;
174
- return _.hasAttribute('hidden');
288
+ get collapsed() {
289
+ return !this.hasAttribute('open');
175
290
  }
176
291
  }
177
292
 
178
- // register custom elements
179
- customElements.define('collapsible-content', CollapsibleContent);
180
- customElements.define('collapsible-component', CollapsibleComponent);
293
+ // register custom elements if not already defined
294
+ if (!customElements.get('collapsible-content')) {
295
+ customElements.define('collapsible-content', CollapsibleContent);
296
+ }
297
+ if (!customElements.get('collapsible-component')) {
298
+ customElements.define('collapsible-component', CollapsibleComponent);
299
+ }
181
300
 
182
301
  export { CollapsibleContent, CollapsibleComponent };
@@ -1,2 +0,0 @@
1
- @forward "scss/variables";
2
- @forward "scss/collapsible-content";
@@ -1,25 +0,0 @@
1
- // Import variables using the modern @use rule
2
- @use 'variables' as vars;
3
-
4
- collapsible-component {
5
- display: var(--cc-component-display, block);
6
-
7
- button {
8
- width: var(--cc-button-width, 100%);
9
- text-align: var(--cc-button-text-align, left);
10
- background: var(--cc-button-background, none);
11
- border: var(--cc-button-border, none);
12
- cursor: var(--cc-button-cursor, pointer);
13
- }
14
-
15
- collapsible-content[hidden] {
16
- display: var(--cc-content-display, block);
17
- }
18
- }
19
-
20
- collapsible-content {
21
- padding: var(--cc-content-padding, 0px);
22
- display: var(--cc-content-display, block);
23
- overflow: var(--cc-content-overflow, hidden);
24
- transition: height var(--cc-transition-duration, 0.35s) var(--cc-transition-timing, ease-out);
25
- }
@@ -1,40 +0,0 @@
1
- // Collapsible Content variables
2
- // SCSS variables for internal component use and customization
3
- // these variables can be customized by the user
4
-
5
- // layout
6
- $component-display: block !default;
7
- $button-width: 100% !default;
8
- $button-text-align: left !default;
9
-
10
- // appearance
11
- $button-background: none !default;
12
- $button-border: none !default;
13
- $button-cursor: pointer !default;
14
-
15
- // animation
16
- $content-padding: 0px !default;
17
- $content-display: block !default;
18
- $content-overflow: hidden !default;
19
- $transition-duration: 0.35s !default;
20
- $transition-timing: ease-out !default;
21
-
22
- // Define CSS Custom Properties using SCSS values
23
- :root {
24
- // layout
25
- --cc-component-display: #{$component-display};
26
- --cc-button-width: #{$button-width};
27
- --cc-button-text-align: #{$button-text-align};
28
-
29
- // appearance
30
- --cc-button-background: #{$button-background};
31
- --cc-button-border: #{$button-border};
32
- --cc-button-cursor: #{$button-cursor};
33
-
34
- // animation
35
- --cc-content-padding: #{$content-padding};
36
- --cc-content-display: #{$content-display};
37
- --cc-content-overflow: #{$content-overflow};
38
- --cc-transition-duration: #{$transition-duration};
39
- --cc-transition-timing: #{$transition-timing};
40
- }
package/src/index.scss DELETED
@@ -1,2 +0,0 @@
1
- @forward "scss/variables";
2
- @forward "scss/collapsible-content";
@@ -1,25 +0,0 @@
1
- // Import variables using the modern @use rule
2
- @use 'variables' as vars;
3
-
4
- collapsible-component {
5
- display: var(--cc-component-display, block);
6
-
7
- button {
8
- width: var(--cc-button-width, 100%);
9
- text-align: var(--cc-button-text-align, left);
10
- background: var(--cc-button-background, none);
11
- border: var(--cc-button-border, none);
12
- cursor: var(--cc-button-cursor, pointer);
13
- }
14
-
15
- collapsible-content[hidden] {
16
- display: var(--cc-content-display, block);
17
- }
18
- }
19
-
20
- collapsible-content {
21
- padding: var(--cc-content-padding, 0px);
22
- display: var(--cc-content-display, block);
23
- overflow: var(--cc-content-overflow, hidden);
24
- transition: height var(--cc-transition-duration, 0.35s) var(--cc-transition-timing, ease-out);
25
- }
@@ -1,40 +0,0 @@
1
- // Collapsible Content variables
2
- // SCSS variables for internal component use and customization
3
- // these variables can be customized by the user
4
-
5
- // layout
6
- $component-display: block !default;
7
- $button-width: 100% !default;
8
- $button-text-align: left !default;
9
-
10
- // appearance
11
- $button-background: none !default;
12
- $button-border: none !default;
13
- $button-cursor: pointer !default;
14
-
15
- // animation
16
- $content-padding: 0px !default;
17
- $content-display: block !default;
18
- $content-overflow: hidden !default;
19
- $transition-duration: 0.35s !default;
20
- $transition-timing: ease-out !default;
21
-
22
- // Define CSS Custom Properties using SCSS values
23
- :root {
24
- // layout
25
- --cc-component-display: #{$component-display};
26
- --cc-button-width: #{$button-width};
27
- --cc-button-text-align: #{$button-text-align};
28
-
29
- // appearance
30
- --cc-button-background: #{$button-background};
31
- --cc-button-border: #{$button-border};
32
- --cc-button-cursor: #{$button-cursor};
33
-
34
- // animation
35
- --cc-content-padding: #{$content-padding};
36
- --cc-content-display: #{$content-display};
37
- --cc-content-overflow: #{$content-overflow};
38
- --cc-transition-duration: #{$transition-duration};
39
- --cc-transition-timing: #{$transition-timing};
40
- }