@magic-spells/collapsible-content 0.2.0 → 1.0.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.
@@ -0,0 +1,31 @@
1
+ collapsible-component {
2
+ display: block;
3
+ }
4
+
5
+ collapsible-component button {
6
+ width: 100%;
7
+ text-align: left;
8
+ background: none;
9
+ border: none;
10
+ cursor: pointer;
11
+ }
12
+
13
+ collapsible-component button:focus-visible {
14
+ outline: 2px solid currentColor;
15
+ outline-offset: 2px;
16
+ }
17
+
18
+ collapsible-content {
19
+ --collapsible-duration: 0.35s;
20
+ --collapsible-easing: ease-out;
21
+
22
+ display: block;
23
+ overflow: hidden;
24
+ transition: height var(--collapsible-duration) var(--collapsible-easing);
25
+ }
26
+
27
+ @media (prefers-reduced-motion: reduce) {
28
+ collapsible-content {
29
+ transition: none;
30
+ }
31
+ }
@@ -1,10 +1,11 @@
1
- import './index.scss';
1
+ import './collapsible-content.css';
2
2
 
3
3
  /**
4
4
  * Custom element that creates a collapsible/expandable component with proper accessibility
5
5
  */
6
6
  class CollapsibleComponent extends HTMLElement {
7
7
  #handleClick;
8
+ #abortController;
8
9
 
9
10
  /**
10
11
  * Initializes the component and sets up references to child elements
@@ -23,7 +24,7 @@ class CollapsibleComponent extends HTMLElement {
23
24
  // toggle expanded value
24
25
  const expanded = _.button.getAttribute('aria-expanded') !== 'true';
25
26
  _.button.setAttribute('aria-expanded', expanded);
26
- _.content.hidden = !expanded;
27
+ _.content.collapsed = !expanded;
27
28
  };
28
29
  }
29
30
 
@@ -39,7 +40,16 @@ class CollapsibleComponent extends HTMLElement {
39
40
  _.content = _.querySelector('collapsible-content');
40
41
 
41
42
  if (!_.button || !_.content) {
42
- console.error('CollapsibleComponent requires a <button> and a <collapsible-content>.');
43
+ const error = new Error(
44
+ 'CollapsibleComponent requires a <button> and a <collapsible-content>.'
45
+ );
46
+ console.error(error.message);
47
+ _.dispatchEvent(
48
+ new CustomEvent('collapsible-error', {
49
+ bubbles: true,
50
+ detail: { error },
51
+ })
52
+ );
43
53
  return;
44
54
  }
45
55
 
@@ -48,18 +58,20 @@ class CollapsibleComponent extends HTMLElement {
48
58
  _.content.id ||= `collapsible-content-${crypto.randomUUID().slice(0, 8)}`;
49
59
 
50
60
  // set accessibility attributes
61
+ _.button.type ||= 'button';
51
62
  _.button.setAttribute('aria-controls', _.content.id);
52
- _.content.setAttribute('role', 'region');
53
63
  _.content.setAttribute('aria-labelledby', _.button.id);
54
64
 
55
- // set initial state based on aria-expanded attribute
56
- const expanded = _.button.getAttribute('aria-expanded') === 'true';
65
+ // set initial state based on open attribute
66
+ const open = _.content.hasAttribute('open');
67
+ _.button.setAttribute('aria-expanded', open);
68
+ _.content.collapsed = !open;
57
69
 
58
- // make sure content state matches button state
59
- _.content.hidden = !expanded;
60
-
61
- // add event listener
62
- _.button.addEventListener('click', _.#handleClick);
70
+ // use AbortController to prevent duplicate listeners on reconnection
71
+ _.#abortController = new AbortController();
72
+ _.button.addEventListener('click', _.#handleClick, {
73
+ signal: _.#abortController.signal,
74
+ });
63
75
  }
64
76
 
65
77
  /**
@@ -68,8 +80,9 @@ class CollapsibleComponent extends HTMLElement {
68
80
  */
69
81
  disconnectedCallback() {
70
82
  const _ = this;
71
- if (_.button) {
72
- _.button.removeEventListener('click', _.#handleClick);
83
+ if (_.#abortController) {
84
+ _.#abortController.abort();
85
+ _.#abortController = null;
73
86
  }
74
87
  }
75
88
  }
@@ -79,6 +92,8 @@ class CollapsibleComponent extends HTMLElement {
79
92
  */
80
93
  class CollapsibleContent extends HTMLElement {
81
94
  #handleTransitionEnd;
95
+ #abortController;
96
+ #animating = false;
82
97
 
83
98
  /**
84
99
  * Initializes the content element and binds event handlers
@@ -90,35 +105,30 @@ class CollapsibleContent extends HTMLElement {
90
105
  // define event handler using arrow function for proper binding
91
106
  _.#handleTransitionEnd = (event) => {
92
107
  // exit if isn't height property
93
- if (event.propertyName != 'height') return;
108
+ if (event.propertyName !== 'height') return;
109
+
110
+ _.#animating = false;
94
111
 
95
112
  // remove the inline height to allow dynamic content changes
96
- if (!_.hidden) {
113
+ if (!_.collapsed) {
97
114
  _.style.height = 'auto';
98
115
  }
99
116
  };
100
-
101
- // add event listener to remove height after transition
102
- _.addEventListener('transitionend', _.#handleTransitionEnd);
103
117
  }
104
118
 
105
119
  /**
106
120
  * Called when element is added to the DOM
107
- * Sets initial height based on hidden state
121
+ * Sets initial height based on open attribute
108
122
  */
109
123
  connectedCallback() {
110
124
  const _ = this;
125
+ _.style.height = _.hasAttribute('open') ? 'auto' : '0';
111
126
 
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
- }
127
+ // use AbortController to prevent duplicate listeners on reconnection
128
+ _.#abortController = new AbortController();
129
+ _.addEventListener('transitionend', _.#handleTransitionEnd, {
130
+ signal: _.#abortController.signal,
131
+ });
122
132
  }
123
133
 
124
134
  /**
@@ -127,56 +137,73 @@ class CollapsibleContent extends HTMLElement {
127
137
  */
128
138
  disconnectedCallback() {
129
139
  const _ = this;
130
- _.removeEventListener('transitionend', _.#handleTransitionEnd);
140
+ if (_.#abortController) {
141
+ _.#abortController.abort();
142
+ _.#abortController = null;
143
+ }
131
144
  }
132
145
 
133
146
  /**
134
- * Handles setting the hidden attribute with animation
135
- * @param {boolean} value - Whether element should be hidden
147
+ * Handles setting the collapsed state with animation
148
+ * @param {boolean} value - Whether element should be collapsed
136
149
  */
137
- set hidden(value) {
150
+ set collapsed(value) {
138
151
  const _ = this;
139
152
 
140
- // animate to closed
153
+ // prevent rapid clicking during animation
154
+ if (_.#animating) return;
155
+
156
+ // check if transitions are enabled (respects prefers-reduced-motion)
157
+ const hasTransition = getComputedStyle(_).transitionDuration !== '0s';
158
+
141
159
  if (value) {
142
- // reset height to animate from
160
+ if (hasTransition) {
161
+ _.#animating = true;
162
+ }
163
+
164
+ // animate to closed
143
165
  _.style.height = `${_.scrollHeight}px`;
144
166
 
145
- // wait one frame and animate to 0
146
- setTimeout(() => {
147
- _.style.height = '0px';
148
- }, 1);
167
+ // use requestAnimationFrame for reliable frame timing
168
+ requestAnimationFrame(() => {
169
+ requestAnimationFrame(() => {
170
+ _.style.height = '0px';
171
+ });
172
+ });
149
173
 
150
- // set accessibility attributes
174
+ _.removeAttribute('open');
151
175
  _.setAttribute('aria-hidden', 'true');
152
176
  _.setAttribute('inert', '');
153
- _.setAttribute('hidden', '');
154
177
  } else {
155
- // only animate if not set to auto
178
+ // only animate if not already auto
156
179
  if (_.style.height !== 'auto') {
157
- // animate to open
180
+ if (hasTransition) {
181
+ _.#animating = true;
182
+ }
158
183
  _.style.height = `${_.scrollHeight}px`;
159
184
  }
160
185
 
161
- // remove accessibility attributes
186
+ _.setAttribute('open', '');
162
187
  _.removeAttribute('aria-hidden');
163
188
  _.removeAttribute('inert');
164
- _.removeAttribute('hidden');
165
189
  }
166
190
  }
167
191
 
168
192
  /**
169
- * Gets the hidden state from the attribute
170
- * @returns {boolean} Whether element is hidden
193
+ * Gets the collapsed state
194
+ * @returns {boolean} Whether element is collapsed
171
195
  */
172
- get hidden() {
173
- const _ = this;
174
- return _.hasAttribute('hidden');
196
+ get collapsed() {
197
+ return !this.hasAttribute('open');
175
198
  }
176
199
  }
177
200
 
178
- // register custom elements
179
- customElements.define('collapsible-content', CollapsibleContent);
180
- customElements.define('collapsible-component', CollapsibleComponent);
201
+ // register custom elements if not already defined
202
+ if (!customElements.get('collapsible-content')) {
203
+ customElements.define('collapsible-content', CollapsibleContent);
204
+ }
205
+ if (!customElements.get('collapsible-component')) {
206
+ customElements.define('collapsible-component', CollapsibleComponent);
207
+ }
181
208
 
182
209
  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
- }