@magic-spells/tab-group 0.1.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,95 +1,19 @@
1
1
  tab-group {
2
2
  display: block;
3
- width: 100%;
4
- font-family: var(--font-family);
5
- font-size: var(--font-size-base);
6
- line-height: var(--line-height);
7
- color: var(--color-text);
8
3
  }
9
4
 
10
5
  tab-list {
11
6
  display: flex;
12
- border-bottom: var(--tab-list-border-bottom, 1px solid var(--color-border));
13
- margin-bottom: var(--spacing-md);
14
7
  overflow-x: auto;
15
8
  overflow-y: hidden;
16
- padding: var(--tab-list-padding, 0.5rem 0.25rem 0);
17
- gap: var(--tab-list-gap, 0.25rem);
18
- justify-content: var(--tab-list-justify, flex-start);
19
- background-color: var(--tab-list-background, transparent);
20
- background-image: var(--tab-list-background-image, none);
21
- border-radius: var(--tab-list-radius, 0);
22
9
  }
23
10
 
24
11
  tab-button {
25
12
  display: block;
26
- padding: var(--spacing-sm) var(--spacing-md);
27
- margin: var(--spacing-xs);
28
- border: 1px solid transparent;
29
- border-bottom: none;
30
- background-color: var(--color-background);
31
- color: var(--color-text);
32
13
  cursor: pointer;
33
- border-radius: var(--tab-button-radius, var(--border-radius) var(--border-radius) 0 0);
34
- white-space: nowrap;
35
14
  user-select: none;
36
- transition: all var(--transition-duration);
37
- margin-bottom: -1px;
38
- position: relative;
39
- font-size: var(--font-size-base);
40
- font-weight: var(--tab-button-font-weight, normal);
41
- text-align: center;
42
- min-width: 100px;
43
- }
44
- tab-button:hover {
45
- background-color: var(--color-hover);
46
- }
47
- tab-button:focus {
48
- outline: none;
49
- box-shadow: var(--tab-button-focus-shadow, 0 0 0 2px var(--color-primary));
50
- }
51
- tab-button[aria-selected=true] {
52
- background-color: var(--tab-active-background, var(--color-background));
53
- border-color: var(--color-border);
54
- border-bottom: 1px solid var(--color-background);
55
- font-weight: var(--tab-active-font-weight, 600);
56
- color: var(--tab-active-color, var(--color-primary));
57
- box-shadow: var(--tab-active-shadow, none);
58
- transform: var(--tab-active-transform, none);
59
- }
60
- tab-button[aria-selected=true]::after {
61
- content: "";
62
- position: absolute;
63
- bottom: 0;
64
- left: var(--tab-indicator-left, 0);
65
- right: var(--tab-indicator-right, 0);
66
- height: var(--tab-indicator-height, 2px);
67
- background-color: var(--tab-indicator-color, var(--color-primary));
68
- border-radius: var(--tab-indicator-radius, 0);
69
15
  }
70
16
 
71
- tab-panel {
72
- display: block;
73
- padding: var(--panel-padding, var(--spacing-md));
74
- background-color: var(--panel-background, var(--color-background));
75
- border-radius: var(--panel-radius, 0 0 var(--border-radius) var(--border-radius));
76
- border: var(--panel-border, none);
77
- border-top: var(--panel-border-top, none);
78
- box-shadow: var(--panel-shadow, none);
79
- transition: var(--panel-transition, all 0.3s ease);
80
- margin: var(--panel-margin, 0);
81
- min-height: var(--panel-min-height, 150px);
82
- }
83
17
  tab-panel[hidden] {
84
18
  display: none;
85
19
  }
86
-
87
- @media (max-width: 768px) {
88
- tab-list {
89
- flex-wrap: wrap;
90
- }
91
- tab-button {
92
- flex: 1 0 auto;
93
- text-align: center;
94
- }
95
- }
@@ -3,23 +3,13 @@
3
3
  * A fully accessible tab group web component
4
4
  */
5
5
 
6
+ let instanceCount = 0;
7
+
6
8
  /**
7
9
  * @class TabGroup
8
10
  * the parent container that coordinates tabs and panels
9
11
  */
10
12
  class TabGroup extends HTMLElement {
11
- // static counter to ensure global unique ids for tabs and panels
12
- static tabCount = 0;
13
- static panelCount = 0;
14
-
15
- constructor() {
16
- super();
17
- // ensure that the number of <tab-button> and <tab-panel> elements match
18
- // note: in some scenarios the child elements might not be available in the constructor,
19
- // so adjust as necessary or consider running this check in connectedCallback()
20
- this.ensureConsistentTabsAndPanels();
21
- }
22
-
23
13
  /**
24
14
  * @function ensureConsistentTabsAndPanels
25
15
  * makes sure there is an equal number of <tab-button> and <tab-panel> elements.
@@ -27,24 +17,24 @@ class TabGroup extends HTMLElement {
27
17
  * if there are more tabs than panels, inject extra panels.
28
18
  */
29
19
  ensureConsistentTabsAndPanels() {
30
- // get current tabs and panels within the tab group
31
- let tabs = this.querySelectorAll("tab-button");
32
- let panels = this.querySelectorAll("tab-panel");
20
+ // get current tabs and panels scoped to direct children only
21
+ let tabs = this.querySelectorAll(':scope > tab-list > tab-button');
22
+ let panels = this.querySelectorAll(':scope > tab-panel');
33
23
 
34
24
  // if there are more panels than tabs
35
25
  if (panels.length > tabs.length) {
36
26
  const difference = panels.length - tabs.length;
37
27
  // try to find a <tab-list> to insert new tabs
38
- let tabList = this.querySelector("tab-list");
28
+ let tabList = this.querySelector(':scope > tab-list');
39
29
  if (!tabList) {
40
30
  // if not present, create one and insert it at the beginning
41
- tabList = document.createElement("tab-list");
31
+ tabList = document.createElement('tab-list');
42
32
  this.insertBefore(tabList, this.firstChild);
43
33
  }
44
34
  // inject extra <tab-button> elements into the tab list
45
35
  for (let i = 0; i < difference; i++) {
46
- const newTab = document.createElement("tab-button");
47
- newTab.textContent = "default tab";
36
+ const newTab = document.createElement('tab-button');
37
+ newTab.textContent = 'default tab';
48
38
  tabList.appendChild(newTab);
49
39
  }
50
40
  }
@@ -53,8 +43,8 @@ class TabGroup extends HTMLElement {
53
43
  const difference = tabs.length - panels.length;
54
44
  // inject extra <tab-panel> elements at the end of the tab group
55
45
  for (let i = 0; i < difference; i++) {
56
- const newPanel = document.createElement("tab-panel");
57
- newPanel.innerHTML = "<p>default panel content</p>";
46
+ const newPanel = document.createElement('tab-panel');
47
+ newPanel.innerHTML = '<p>default panel content</p>';
58
48
  this.appendChild(newPanel);
59
49
  }
60
50
  }
@@ -64,58 +54,149 @@ class TabGroup extends HTMLElement {
64
54
  * called when the element is connected to the dom
65
55
  */
66
56
  connectedCallback() {
67
- const _ = this;
57
+ // assign a stable instance id on first connect
58
+ if (!this._instanceId) {
59
+ this._instanceId = `tg-${instanceCount++}`;
60
+ }
61
+
62
+ // ensure that the number of <tab-button> and <tab-panel> elements match
63
+ this.ensureConsistentTabsAndPanels();
68
64
 
69
65
  // find the <tab-list> element (should be exactly one)
70
- _.tabList = _.querySelector("tab-list");
71
- if (!_.tabList) return;
66
+ this.tabList = this.querySelector(':scope > tab-list');
67
+ if (!this.tabList) return;
72
68
 
73
69
  // find all <tab-button> elements inside the <tab-list>
74
- _.tabButtons = Array.from(_.tabList.querySelectorAll("tab-button"));
70
+ this.tabButtons = Array.from(
71
+ this.tabList.querySelectorAll('tab-button')
72
+ );
75
73
 
76
74
  // find all <tab-panel> elements inside the <tab-group>
77
- _.tabPanels = Array.from(_.querySelectorAll("tab-panel"));
75
+ this.tabPanels = Array.from(this.querySelectorAll(':scope > tab-panel'));
78
76
 
79
- // initialize each tab-button with roles, ids and aria attributes
80
- _.tabButtons.forEach((tab, index) => {
81
- const tabIndex = TabGroup.tabCount++;
77
+ const prefix = this._instanceId;
82
78
 
83
- // generate a unique id for each tab, e.g. "tab-0", "tab-1", ...
84
- const tabId = `tab-${tabIndex}`;
79
+ // initialize each tab-button with roles, ids and aria attributes
80
+ this.tabButtons.forEach((tab, index) => {
81
+ const tabId = `${prefix}-tab-${index}`;
82
+ const panelId = `${prefix}-panel-${index}`;
85
83
  tab.id = tabId;
86
-
87
- // generate a corresponding panel id, e.g. "panel-0"
88
- const panelId = `panel-${tabIndex}`;
89
- tab.setAttribute("role", "tab");
90
- tab.setAttribute("aria-controls", panelId);
84
+ tab.setAttribute('role', 'tab');
85
+ tab.setAttribute('aria-controls', panelId);
91
86
 
92
87
  // first tab is active by default
93
88
  if (index === 0) {
94
- tab.setAttribute("aria-selected", "true");
95
- tab.setAttribute("tabindex", "0");
89
+ tab.setAttribute('aria-selected', 'true');
90
+ tab.setAttribute('tabindex', '0');
96
91
  } else {
97
- tab.setAttribute("aria-selected", "false");
98
- tab.setAttribute("tabindex", "-1");
92
+ tab.setAttribute('aria-selected', 'false');
93
+ tab.setAttribute('tabindex', '-1');
99
94
  }
100
95
  });
101
96
 
102
97
  // initialize each tab-panel with roles, ids and aria attributes
103
- _.tabPanels.forEach((panel, index) => {
104
- const panelIndex = TabGroup.panelCount++;
105
- const panelId = `panel-${panelIndex}`;
98
+ this.tabPanels.forEach((panel, index) => {
99
+ const panelId = `${prefix}-panel-${index}`;
106
100
  panel.id = panelId;
107
-
108
- panel.setAttribute("role", "tabpanel");
109
- panel.setAttribute("aria-labelledby", `tab-${panelIndex}`);
101
+ panel.setAttribute('role', 'tabpanel');
102
+ panel.setAttribute('aria-labelledby', `${prefix}-tab-${index}`);
110
103
 
111
104
  // hide panels except for the first one
112
105
  panel.hidden = index !== 0;
113
106
  });
114
107
 
115
108
  // set up keyboard navigation and click delegation on the <tab-list>
116
- _.tabList.setAttribute("role", "tablist");
117
- _.tabList.addEventListener("keydown", (e) => _.onKeyDown(e));
118
- _.tabList.addEventListener("click", (e) => _.onClick(e));
109
+ this.tabList.setAttribute('role', 'tablist');
110
+
111
+ // store bound handlers so we can remove them in disconnectedCallback
112
+ if (!this._onKeyDown) {
113
+ this._onKeyDown = (e) => this.onKeyDown(e);
114
+ this._onClick = (e) => this.onClick(e);
115
+ }
116
+ this.tabList.addEventListener('keydown', this._onKeyDown);
117
+ this.tabList.addEventListener('click', this._onClick);
118
+ }
119
+
120
+ /**
121
+ * called when the element is disconnected from the dom
122
+ */
123
+ disconnectedCallback() {
124
+ if (this._animationController) {
125
+ this._animationController.abort();
126
+ this._animationController = null;
127
+ }
128
+ if (this.tabList && this._onKeyDown) {
129
+ this.tabList.removeEventListener('keydown', this._onKeyDown);
130
+ this.tabList.removeEventListener('click', this._onClick);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * reads animation attributes from the element
136
+ */
137
+ _getAnimateConfig() {
138
+ const outClass = this.getAttribute('animate-out-class');
139
+ const inClass = this.getAttribute('animate-in-class');
140
+ const timeout = parseInt(this.getAttribute('animate-timeout'), 10) || 500;
141
+ return { outClass, inClass, timeout, hasAnimation: !!(outClass || inClass) };
142
+ }
143
+
144
+ /**
145
+ * adds a class and waits for animationend (or timeout), with abort support
146
+ */
147
+ _waitForAnimation(element, className, timeout, signal) {
148
+ return new Promise((resolve) => {
149
+ if (signal.aborted) {
150
+ resolve();
151
+ return;
152
+ }
153
+
154
+ element.classList.add(className);
155
+
156
+ let timer;
157
+ const cleanup = () => {
158
+ element.classList.remove(className);
159
+ clearTimeout(timer);
160
+ element.removeEventListener('animationend', onEnd);
161
+ signal.removeEventListener('abort', onAbort);
162
+ resolve();
163
+ };
164
+
165
+ const onEnd = (e) => {
166
+ if (e.target === element) cleanup();
167
+ };
168
+
169
+ const onAbort = () => cleanup();
170
+
171
+ element.addEventListener('animationend', onEnd);
172
+ signal.addEventListener('abort', onAbort);
173
+ timer = setTimeout(cleanup, timeout);
174
+ });
175
+ }
176
+
177
+ /**
178
+ * orchestrates out-animation → swap → in-animation
179
+ */
180
+ async _animateTransition(oldPanel, newPanel, config, controller) {
181
+ const { signal } = controller;
182
+
183
+ // Phase 1: animate out
184
+ if (config.outClass && oldPanel) {
185
+ await this._waitForAnimation(oldPanel, config.outClass, config.timeout, signal);
186
+ }
187
+ if (signal.aborted) return;
188
+
189
+ // Phase 2: swap hidden
190
+ if (oldPanel) oldPanel.hidden = true;
191
+ newPanel.hidden = false;
192
+
193
+ // Phase 3: animate in
194
+ if (config.inClass) {
195
+ if (signal.aborted) return;
196
+ // force reflow so the browser sees the element before animating
197
+ newPanel.offsetHeight;
198
+ await this._waitForAnimation(newPanel, config.inClass, config.timeout, signal);
199
+ }
119
200
  }
120
201
 
121
202
  /**
@@ -124,35 +205,86 @@ class TabGroup extends HTMLElement {
124
205
  * @param {number} index - index of the tab to activate
125
206
  */
126
207
  setActiveTab(index) {
127
- const _ = this;
128
- const previousIndex = _.tabButtons.findIndex(tab => tab.getAttribute("aria-selected") === "true");
208
+ if (index < 0 || index >= this.tabButtons.length) return;
209
+ const previousIndex = this.tabButtons.findIndex(
210
+ (tab) => tab.getAttribute('aria-selected') === 'true'
211
+ );
212
+
213
+ // cancel any in-flight animation
214
+ if (this._animationController) {
215
+ this._animationController.abort();
216
+ this._animationController = null;
217
+ // force-hide all panels (clean slate)
218
+ this.tabPanels.forEach((panel) => {
219
+ panel.hidden = true;
220
+ });
221
+ }
129
222
 
130
- // update each tab-button
131
- _.tabButtons.forEach((tab, i) => {
223
+ // update each tab-button (ARIA updates fire immediately)
224
+ this.tabButtons.forEach((tab, i) => {
132
225
  const isActive = i === index;
133
- tab.setAttribute("aria-selected", isActive ? "true" : "false");
134
- tab.setAttribute("tabindex", isActive ? "0" : "-1");
226
+ tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
227
+ tab.setAttribute('tabindex', isActive ? '0' : '-1');
135
228
  if (isActive) {
136
229
  tab.focus();
137
230
  }
138
231
  });
139
232
 
140
- // update each tab-panel
141
- _.tabPanels.forEach((panel, i) => {
142
- panel.hidden = i !== index;
143
- });
144
-
145
233
  // dispatch event only if the tab actually changed
146
234
  if (previousIndex !== index) {
147
235
  const detail = {
148
236
  previousIndex,
149
237
  currentIndex: index,
150
- previousTab: _.tabButtons[previousIndex],
151
- currentTab: _.tabButtons[index],
152
- previousPanel: _.tabPanels[previousIndex],
153
- currentPanel: _.tabPanels[index]
238
+ previousTab: this.tabButtons[previousIndex],
239
+ currentTab: this.tabButtons[index],
240
+ previousPanel: this.tabPanels[previousIndex],
241
+ currentPanel: this.tabPanels[index],
154
242
  };
155
- _.dispatchEvent(new CustomEvent('tabchange', { detail, bubbles: true }));
243
+ this.dispatchEvent(
244
+ new CustomEvent('tabchange', { detail, bubbles: true })
245
+ );
246
+ }
247
+
248
+ const config = this._getAnimateConfig();
249
+ const oldPanel = previousIndex >= 0 ? this.tabPanels[previousIndex] : null;
250
+ const newPanel = this.tabPanels[index];
251
+
252
+ if (!config.hasAnimation || previousIndex === index) {
253
+ // instant switch (original behavior)
254
+ this.tabPanels.forEach((panel, i) => {
255
+ panel.hidden = i !== index;
256
+ });
257
+ return;
258
+ }
259
+
260
+ // animated transition
261
+ const controller = new AbortController();
262
+ this._animationController = controller;
263
+
264
+ // old panel was already force-hidden by abort above, so if we aborted
265
+ // a previous animation, skip animate-out (old panel is already gone)
266
+ const skipOut = oldPanel && oldPanel.hidden;
267
+
268
+ if (skipOut) {
269
+ // just animate in the new panel
270
+ newPanel.hidden = false;
271
+ if (config.inClass) {
272
+ newPanel.offsetHeight;
273
+ this._waitForAnimation(newPanel, config.inClass, config.timeout, controller.signal).then(() => {
274
+ if (this._animationController === controller) {
275
+ this._animationController = null;
276
+ }
277
+ });
278
+ } else {
279
+ this._animationController = null;
280
+ }
281
+ } else {
282
+ // full out → swap → in sequence
283
+ this._animateTransition(oldPanel, newPanel, config, controller).then(() => {
284
+ if (this._animationController === controller) {
285
+ this._animationController = null;
286
+ }
287
+ });
156
288
  }
157
289
  }
158
290
 
@@ -162,17 +294,16 @@ class TabGroup extends HTMLElement {
162
294
  * @param {MouseEvent} e - the click event
163
295
  */
164
296
  onClick(e) {
165
- const _ = this;
166
297
  // check if the click occurred on or within a <tab-button>
167
- const tabButton = e.target.closest("tab-button");
298
+ const tabButton = e.target.closest('tab-button');
168
299
  if (!tabButton) return;
169
300
 
170
301
  // determine the index of the clicked tab-button
171
- const index = _.tabButtons.indexOf(tabButton);
302
+ const index = this.tabButtons.indexOf(tabButton);
172
303
  if (index === -1) return;
173
304
 
174
305
  // activate the tab with the corresponding index
175
- _.setActiveTab(index);
306
+ this.setActiveTab(index);
176
307
  }
177
308
 
178
309
  /**
@@ -181,39 +312,39 @@ class TabGroup extends HTMLElement {
181
312
  * @param {KeyboardEvent} e - the keydown event
182
313
  */
183
314
  onKeyDown(e) {
184
- const _ = this;
185
315
  // only process keys if focus is on a <tab-button>
186
- const targetIndex = _.tabButtons.indexOf(e.target);
316
+ const targetIndex = this.tabButtons.indexOf(e.target);
187
317
  if (targetIndex === -1) return;
188
318
 
189
319
  let newIndex = targetIndex;
190
320
  switch (e.key) {
191
- case "ArrowLeft":
192
- case "ArrowUp":
321
+ case 'ArrowLeft':
322
+ case 'ArrowUp':
193
323
  // move to the previous tab (wrap around if necessary)
194
- newIndex = targetIndex > 0 ? targetIndex - 1 : _.tabButtons.length - 1;
324
+ newIndex =
325
+ targetIndex > 0 ? targetIndex - 1 : this.tabButtons.length - 1;
195
326
  e.preventDefault();
196
327
  break;
197
- case "ArrowRight":
198
- case "ArrowDown":
328
+ case 'ArrowRight':
329
+ case 'ArrowDown':
199
330
  // move to the next tab (wrap around if necessary)
200
- newIndex = (targetIndex + 1) % _.tabButtons.length;
331
+ newIndex = (targetIndex + 1) % this.tabButtons.length;
201
332
  e.preventDefault();
202
333
  break;
203
- case "Home":
334
+ case 'Home':
204
335
  // jump to the first tab
205
336
  newIndex = 0;
206
337
  e.preventDefault();
207
338
  break;
208
- case "End":
339
+ case 'End':
209
340
  // jump to the last tab
210
- newIndex = _.tabButtons.length - 1;
341
+ newIndex = this.tabButtons.length - 1;
211
342
  e.preventDefault();
212
343
  break;
213
344
  default:
214
345
  return; // ignore other keys
215
346
  }
216
- _.setActiveTab(newIndex);
347
+ this.setActiveTab(newIndex);
217
348
  }
218
349
  }
219
350
 
@@ -221,49 +352,31 @@ class TabGroup extends HTMLElement {
221
352
  * @class TabList
222
353
  * a container for the <tab-button> elements
223
354
  */
224
- class TabList extends HTMLElement {
225
- constructor() {
226
- super();
227
- }
228
-
229
- connectedCallback() {
230
- // additional logic or styling can be added here if desired
231
- }
232
- }
355
+ class TabList extends HTMLElement {}
233
356
 
234
357
  /**
235
358
  * @class TabButton
236
359
  * a single tab button element
237
360
  */
238
- class TabButton extends HTMLElement {
239
- constructor() {
240
- super();
241
- }
242
-
243
- connectedCallback() {
244
- // note: role and other attributes are handled by the parent
245
- }
246
- }
361
+ class TabButton extends HTMLElement {}
247
362
 
248
363
  /**
249
364
  * @class TabPanel
250
365
  * a single tab panel element
251
366
  */
252
- class TabPanel extends HTMLElement {
253
- constructor() {
254
- super();
255
- }
256
-
257
- connectedCallback() {
258
- // note: role and other attributes are handled by the parent
259
- }
367
+ class TabPanel extends HTMLElement {}
368
+
369
+ // define the custom elements (guarded against double-registration and SSR)
370
+ if (typeof window !== 'undefined' && window.customElements) {
371
+ if (!customElements.get('tab-group'))
372
+ customElements.define('tab-group', TabGroup);
373
+ if (!customElements.get('tab-list'))
374
+ customElements.define('tab-list', TabList);
375
+ if (!customElements.get('tab-button'))
376
+ customElements.define('tab-button', TabButton);
377
+ if (!customElements.get('tab-panel'))
378
+ customElements.define('tab-panel', TabPanel);
260
379
  }
261
380
 
262
- // define the custom elements
263
- customElements.define("tab-group", TabGroup);
264
- customElements.define("tab-list", TabList);
265
- customElements.define("tab-button", TabButton);
266
- customElements.define("tab-panel", TabPanel);
267
-
268
- export { TabGroup, TabGroup as default };
381
+ export { TabGroup as default };
269
382
  //# sourceMappingURL=tab-group.esm.js.map