@magic-spells/collapsible-content 0.1.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-spells/collapsible-content",
3
- "version": "0.1.4",
3
+ "version": "1.0.0",
4
4
  "description": "Collapsible content web component",
5
5
  "author": "Cory Schulz",
6
6
  "license": "MIT",
@@ -15,7 +15,8 @@
15
15
  "require": "./dist/collapsible-content.cjs.js",
16
16
  "default": "./dist/collapsible-content.esm.js"
17
17
  },
18
- "./style.css": "./dist/collapsible-content.min.css"
18
+ "./css": "./dist/collapsible-content.css",
19
+ "./css/min": "./dist/collapsible-content.min.css"
19
20
  },
20
21
  "sideEffects": true,
21
22
  "repository": {
@@ -56,16 +57,16 @@
56
57
  "not ie <= 11"
57
58
  ],
58
59
  "devDependencies": {
59
- "@eslint/js": "^8.57.0",
60
+ "@eslint/js": "^9.38.0",
60
61
  "@rollup/plugin-node-resolve": "^15.2.3",
61
62
  "@rollup/plugin-terser": "^0.4.4",
62
- "eslint": "^8.0.0",
63
- "globals": "^13.24.0",
63
+ "eslint": "^9.38.0",
64
+ "globals": "^15.15.0",
65
+ "postcss-lightningcss": "^1.0.1",
64
66
  "prettier": "^3.3.3",
65
67
  "rollup": "^3.0.0",
66
68
  "rollup-plugin-copy": "^3.5.0",
67
69
  "rollup-plugin-postcss": "^4.0.2",
68
- "rollup-plugin-serve": "^1.1.1",
69
- "postcss-lightningcss": "^1.0.1"
70
+ "rollup-plugin-serve": "^1.1.1"
70
71
  }
71
72
  }
@@ -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,177 +1,209 @@
1
- import './styles.css';
1
+ import './collapsible-content.css';
2
2
 
3
3
  /**
4
- * custom element that creates a collapsible/expandable component with proper accessibility
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
- * initializes the component and sets up references to child elements
11
+ * Initializes the component and sets up references to child elements
11
12
  */
12
13
  constructor() {
13
14
  super();
15
+ const _ = this;
14
16
 
15
17
  // store references to elements once to avoid re-querying
16
- this.button = null;
17
- this.content = null;
18
+ _.button = null;
19
+ _.content = null;
18
20
 
19
21
  // define click handler with proper this binding
20
- this.#handleClick = (e) => {
22
+ _.#handleClick = (e) => {
21
23
  e.preventDefault();
22
24
  // toggle expanded value
23
- const expanded = this.button.getAttribute('aria-expanded') !== 'true';
24
- this.button.setAttribute('aria-expanded', expanded);
25
- this.content.hidden = !expanded;
25
+ const expanded = _.button.getAttribute('aria-expanded') !== 'true';
26
+ _.button.setAttribute('aria-expanded', expanded);
27
+ _.content.collapsed = !expanded;
26
28
  };
27
29
  }
28
30
 
29
31
  /**
30
- * called when element is added to the dom
31
- * sets up accessibility attributes and event listeners
32
+ * Called when element is added to the DOM
33
+ * Sets up accessibility attributes and event listeners
32
34
  */
33
35
  connectedCallback() {
34
- // initialize element references once
35
- this.button = this.querySelector('button');
36
- this.content = this.querySelector('collapsible-content');
36
+ const _ = this;
37
37
 
38
- if (!this.button || !this.content) {
39
- console.error('CollapsibleComponent requires a <button> and a <collapsible-content>.');
38
+ // initialize element references once
39
+ _.button = _.querySelector('button');
40
+ _.content = _.querySelector('collapsible-content');
41
+
42
+ if (!_.button || !_.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
+ );
40
53
  return;
41
54
  }
42
55
 
43
56
  // generate ids if not provided
44
- this.button.id ||= `collapsible-button-${crypto.randomUUID().slice(0, 8)}`;
45
- this.content.id ||= `collapsible-content-${crypto.randomUUID().slice(0, 8)}`;
57
+ _.button.id ||= `collapsible-button-${crypto.randomUUID().slice(0, 8)}`;
58
+ _.content.id ||= `collapsible-content-${crypto.randomUUID().slice(0, 8)}`;
46
59
 
47
60
  // set accessibility attributes
48
- this.button.setAttribute('aria-controls', this.content.id);
49
- this.content.setAttribute('role', 'region');
50
- this.content.setAttribute('aria-labelledby', this.button.id);
51
-
52
- // set initial state based on aria-expanded attribute
53
- const expanded = this.button.getAttribute('aria-expanded') === 'true';
54
-
55
- // make shure content state matches button state
56
- this.content.hidden = !expanded;
57
-
58
- // add event listener
59
- this.button.addEventListener('click', this.#handleClick);
61
+ _.button.type ||= 'button';
62
+ _.button.setAttribute('aria-controls', _.content.id);
63
+ _.content.setAttribute('aria-labelledby', _.button.id);
64
+
65
+ // set initial state based on open attribute
66
+ const open = _.content.hasAttribute('open');
67
+ _.button.setAttribute('aria-expanded', open);
68
+ _.content.collapsed = !open;
69
+
70
+ // use AbortController to prevent duplicate listeners on reconnection
71
+ _.#abortController = new AbortController();
72
+ _.button.addEventListener('click', _.#handleClick, {
73
+ signal: _.#abortController.signal,
74
+ });
60
75
  }
61
76
 
62
77
  /**
63
- * called when element is removed from the dom
64
- * cleans up event listeners
78
+ * Called when element is removed from the DOM
79
+ * Cleans up event listeners
65
80
  */
66
81
  disconnectedCallback() {
67
- if (this.button) {
68
- this.button.removeEventListener('click', this.#handleClick);
82
+ const _ = this;
83
+ if (_.#abortController) {
84
+ _.#abortController.abort();
85
+ _.#abortController = null;
69
86
  }
70
87
  }
71
88
  }
72
89
 
73
90
  /**
74
- * custom element that provides animated collapsible content
91
+ * Custom element that provides animated collapsible content
75
92
  */
76
93
  class CollapsibleContent extends HTMLElement {
94
+ #handleTransitionEnd;
95
+ #abortController;
96
+ #animating = false;
97
+
77
98
  /**
78
- * initializes the content element and binds event handlers
99
+ * Initializes the content element and binds event handlers
79
100
  */
80
101
  constructor() {
81
102
  super();
103
+ const _ = this;
82
104
 
83
- // bind event handler to this instance
84
- this._onTransitionEnd = this._onTransitionEnd.bind(this);
105
+ // define event handler using arrow function for proper binding
106
+ _.#handleTransitionEnd = (event) => {
107
+ // exit if isn't height property
108
+ if (event.propertyName !== 'height') return;
85
109
 
86
- // add event listener to remove height after transition
87
- this.addEventListener('transitionend', this._onTransitionEnd);
110
+ _.#animating = false;
111
+
112
+ // remove the inline height to allow dynamic content changes
113
+ if (!_.collapsed) {
114
+ _.style.height = 'auto';
115
+ }
116
+ };
88
117
  }
89
118
 
90
119
  /**
91
- * called when element is added to the dom
92
- * sets initial height based on hidden state
120
+ * Called when element is added to the DOM
121
+ * Sets initial height based on open attribute
93
122
  */
94
123
  connectedCallback() {
95
- // get aria-expanded state from buton
96
- const component = this.closest('collapsible-component');
97
- const button = component.querySelector('button');
98
- const expanded = button.getAttribute('aria-expanded') === 'true';
99
-
100
- if (expanded) {
101
- this.style.height = 'auto';
102
- } else {
103
- this.style.height = '0';
104
- }
124
+ const _ = this;
125
+ _.style.height = _.hasAttribute('open') ? 'auto' : '0';
126
+
127
+ // use AbortController to prevent duplicate listeners on reconnection
128
+ _.#abortController = new AbortController();
129
+ _.addEventListener('transitionend', _.#handleTransitionEnd, {
130
+ signal: _.#abortController.signal,
131
+ });
105
132
  }
106
133
 
107
134
  /**
108
- * called when element is removed from the dom
109
- * cleans up event listeners
135
+ * Called when element is removed from the DOM
136
+ * Cleans up event listeners
110
137
  */
111
138
  disconnectedCallback() {
112
- this.removeEventListener('transitionend', this._onTransitionEnd);
139
+ const _ = this;
140
+ if (_.#abortController) {
141
+ _.#abortController.abort();
142
+ _.#abortController = null;
143
+ }
113
144
  }
114
145
 
115
146
  /**
116
- * handles setting the hidden attribute with animation
117
- * @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
118
149
  */
119
- set hidden(value) {
120
- // animate to closed
150
+ set collapsed(value) {
151
+ const _ = this;
152
+
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
+
121
159
  if (value) {
122
- // reset height to animate from
123
- this.style.height = `${this.scrollHeight}px`;
124
-
125
- // wait one frame and animate to 0
126
- setTimeout(() => {
127
- this.style.height = '0px';
128
- }, 1);
129
-
130
- // set accessibility attributes
131
- this.setAttribute('aria-hidden', 'true');
132
- this.setAttribute('inert', '');
133
- this.setAttribute('hidden', '');
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
+
174
+ _.removeAttribute('open');
175
+ _.setAttribute('aria-hidden', 'true');
176
+ _.setAttribute('inert', '');
134
177
  } else {
135
- // only animate if not set to auto
136
- if (this.style.height !== 'auto') {
137
- // animate to open
138
- this.style.height = `${this.scrollHeight}px`;
178
+ // only animate if not already auto
179
+ if (_.style.height !== 'auto') {
180
+ if (hasTransition) {
181
+ _.#animating = true;
182
+ }
183
+ _.style.height = `${_.scrollHeight}px`;
139
184
  }
140
185
 
141
- // remove accessibility attributes
142
- this.removeAttribute('aria-hidden');
143
- this.removeAttribute('inert');
144
- this.removeAttribute('hidden');
186
+ _.setAttribute('open', '');
187
+ _.removeAttribute('aria-hidden');
188
+ _.removeAttribute('inert');
145
189
  }
146
190
  }
147
191
 
148
192
  /**
149
- * gets the hidden state from the attribute
150
- * @return {boolean} whether element is hidden
151
- */
152
- get hidden() {
153
- return this.hasAttribute('hidden');
154
- }
155
-
156
- /**
157
- * handles the end of css transitions
158
- * @param {TransitionEvent} event - the transition event
193
+ * Gets the collapsed state
194
+ * @returns {boolean} Whether element is collapsed
159
195
  */
160
- _onTransitionEnd(event) {
161
- console.log('on transition end');
162
-
163
- // exit if isn't height property
164
- if (event.propertyName != 'height') return;
165
-
166
- // remove the inline height to allow dynamic content changes
167
- if (!this.hidden) {
168
- this.style.height = 'auto';
169
- }
196
+ get collapsed() {
197
+ return !this.hasAttribute('open');
170
198
  }
171
199
  }
172
200
 
173
- // register custom elements
174
- customElements.define('collapsible-content', CollapsibleContent);
175
- 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
+ }
176
208
 
177
209
  export { CollapsibleContent, CollapsibleComponent };
package/src/styles.css DELETED
@@ -1,22 +0,0 @@
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-content {
14
- padding: 0px;
15
- display: block;
16
- overflow: hidden;
17
- transition: height 0.35s ease-out;
18
- }
19
-
20
- collapsible-component collapsible-content[hidden] {
21
- display: block;
22
- }