@magic-spells/tab-group 0.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.
@@ -0,0 +1,277 @@
1
+ import './index.scss';
2
+
3
+ /**
4
+ * @module TabGroup
5
+ * A fully accessible tab group web component
6
+ */
7
+
8
+ /**
9
+ * @class TabGroup
10
+ * the parent container that coordinates tabs and panels
11
+ */
12
+ export default class TabGroup extends HTMLElement {
13
+ // static counter to ensure global unique ids for tabs and panels
14
+ static tabCount = 0;
15
+ static panelCount = 0;
16
+
17
+ constructor() {
18
+ super();
19
+ // ensure that the number of <tab-button> and <tab-panel> elements match
20
+ // note: in some scenarios the child elements might not be available in the constructor,
21
+ // so adjust as necessary or consider running this check in connectedCallback()
22
+ this.ensureConsistentTabsAndPanels();
23
+ }
24
+
25
+ /**
26
+ * @function ensureConsistentTabsAndPanels
27
+ * makes sure there is an equal number of <tab-button> and <tab-panel> elements.
28
+ * if there are more panels than tabs, inject extra tab buttons.
29
+ * if there are more tabs than panels, inject extra panels.
30
+ */
31
+ ensureConsistentTabsAndPanels() {
32
+ // get current tabs and panels within the tab group
33
+ let tabs = this.querySelectorAll("tab-button");
34
+ let panels = this.querySelectorAll("tab-panel");
35
+
36
+ // if there are more panels than tabs
37
+ if (panels.length > tabs.length) {
38
+ const difference = panels.length - tabs.length;
39
+ // try to find a <tab-list> to insert new tabs
40
+ let tabList = this.querySelector("tab-list");
41
+ if (!tabList) {
42
+ // if not present, create one and insert it at the beginning
43
+ tabList = document.createElement("tab-list");
44
+ this.insertBefore(tabList, this.firstChild);
45
+ }
46
+ // inject extra <tab-button> elements into the tab list
47
+ for (let i = 0; i < difference; i++) {
48
+ const newTab = document.createElement("tab-button");
49
+ newTab.textContent = "default tab";
50
+ tabList.appendChild(newTab);
51
+ }
52
+ }
53
+ // if there are more tabs than panels
54
+ else if (tabs.length > panels.length) {
55
+ const difference = tabs.length - panels.length;
56
+ // inject extra <tab-panel> elements at the end of the tab group
57
+ for (let i = 0; i < difference; i++) {
58
+ const newPanel = document.createElement("tab-panel");
59
+ newPanel.innerHTML = "<p>default panel content</p>";
60
+ this.appendChild(newPanel);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * called when the element is connected to the dom
67
+ */
68
+ connectedCallback() {
69
+ const _ = this;
70
+
71
+ // find the <tab-list> element (should be exactly one)
72
+ _.tabList = _.querySelector("tab-list");
73
+ if (!_.tabList) return;
74
+
75
+ // find all <tab-button> elements inside the <tab-list>
76
+ _.tabButtons = Array.from(_.tabList.querySelectorAll("tab-button"));
77
+
78
+ // find all <tab-panel> elements inside the <tab-group>
79
+ _.tabPanels = Array.from(_.querySelectorAll("tab-panel"));
80
+
81
+ // initialize each tab-button with roles, ids and aria attributes
82
+ _.tabButtons.forEach((tab, index) => {
83
+ const tabIndex = TabGroup.tabCount++;
84
+
85
+ // generate a unique id for each tab, e.g. "tab-0", "tab-1", ...
86
+ const tabId = `tab-${tabIndex}`;
87
+ tab.id = tabId;
88
+
89
+ // generate a corresponding panel id, e.g. "panel-0"
90
+ const panelId = `panel-${tabIndex}`;
91
+ tab.setAttribute("role", "tab");
92
+ tab.setAttribute("aria-controls", panelId);
93
+
94
+ // first tab is active by default
95
+ if (index === 0) {
96
+ tab.setAttribute("aria-selected", "true");
97
+ tab.setAttribute("tabindex", "0");
98
+ } else {
99
+ tab.setAttribute("aria-selected", "false");
100
+ tab.setAttribute("tabindex", "-1");
101
+ }
102
+ });
103
+
104
+ // initialize each tab-panel with roles, ids and aria attributes
105
+ _.tabPanels.forEach((panel, index) => {
106
+ const panelIndex = TabGroup.panelCount++;
107
+ const panelId = `panel-${panelIndex}`;
108
+ panel.id = panelId;
109
+
110
+ panel.setAttribute("role", "tabpanel");
111
+ panel.setAttribute("aria-labelledby", `tab-${panelIndex}`);
112
+
113
+ // hide panels except for the first one
114
+ panel.hidden = index !== 0;
115
+ });
116
+
117
+ // set up keyboard navigation and click delegation on the <tab-list>
118
+ _.tabList.setAttribute("role", "tablist");
119
+ _.tabList.addEventListener("keydown", (e) => _.onKeyDown(e));
120
+ _.tabList.addEventListener("click", (e) => _.onClick(e));
121
+ }
122
+
123
+ /**
124
+ * @function setActiveTab
125
+ * activates a tab and updates aria attributes
126
+ * @param {number} index - index of the tab to activate
127
+ */
128
+ setActiveTab(index) {
129
+ const _ = this;
130
+ const previousIndex = _.tabButtons.findIndex(tab => tab.getAttribute("aria-selected") === "true");
131
+
132
+ // update each tab-button
133
+ _.tabButtons.forEach((tab, i) => {
134
+ const isActive = i === index;
135
+ tab.setAttribute("aria-selected", isActive ? "true" : "false");
136
+ tab.setAttribute("tabindex", isActive ? "0" : "-1");
137
+ if (isActive) {
138
+ tab.focus();
139
+ }
140
+ });
141
+
142
+ // update each tab-panel
143
+ _.tabPanels.forEach((panel, i) => {
144
+ panel.hidden = i !== index;
145
+ });
146
+
147
+ // dispatch event only if the tab actually changed
148
+ if (previousIndex !== index) {
149
+ const detail = {
150
+ previousIndex,
151
+ currentIndex: index,
152
+ previousTab: _.tabButtons[previousIndex],
153
+ currentTab: _.tabButtons[index],
154
+ previousPanel: _.tabPanels[previousIndex],
155
+ currentPanel: _.tabPanels[index]
156
+ };
157
+ _.dispatchEvent(new CustomEvent('tabchange', { detail, bubbles: true }));
158
+ }
159
+ }
160
+
161
+ /**
162
+ * @function onClick
163
+ * handles click events on the <tab-list> via event delegation
164
+ * @param {MouseEvent} e - the click event
165
+ */
166
+ onClick(e) {
167
+ const _ = this;
168
+ // check if the click occurred on or within a <tab-button>
169
+ const tabButton = e.target.closest("tab-button");
170
+ if (!tabButton) return;
171
+
172
+ // determine the index of the clicked tab-button
173
+ const index = _.tabButtons.indexOf(tabButton);
174
+ if (index === -1) return;
175
+
176
+ // activate the tab with the corresponding index
177
+ _.setActiveTab(index);
178
+ }
179
+
180
+ /**
181
+ * @function onKeyDown
182
+ * handles keyboard navigation for the tabs
183
+ * @param {KeyboardEvent} e - the keydown event
184
+ */
185
+ onKeyDown(e) {
186
+ const _ = this;
187
+ // only process keys if focus is on a <tab-button>
188
+ const targetIndex = _.tabButtons.indexOf(e.target);
189
+ if (targetIndex === -1) return;
190
+
191
+ let newIndex = targetIndex;
192
+ switch (e.key) {
193
+ case "ArrowLeft":
194
+ case "ArrowUp":
195
+ // move to the previous tab (wrap around if necessary)
196
+ newIndex = targetIndex > 0 ? targetIndex - 1 : _.tabButtons.length - 1;
197
+ e.preventDefault();
198
+ break;
199
+ case "ArrowRight":
200
+ case "ArrowDown":
201
+ // move to the next tab (wrap around if necessary)
202
+ newIndex = (targetIndex + 1) % _.tabButtons.length;
203
+ e.preventDefault();
204
+ break;
205
+ case "Home":
206
+ // jump to the first tab
207
+ newIndex = 0;
208
+ e.preventDefault();
209
+ break;
210
+ case "End":
211
+ // jump to the last tab
212
+ newIndex = _.tabButtons.length - 1;
213
+ e.preventDefault();
214
+ break;
215
+ default:
216
+ return; // ignore other keys
217
+ }
218
+ _.setActiveTab(newIndex);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * @class TabList
224
+ * a container for the <tab-button> elements
225
+ */
226
+ class TabList extends HTMLElement {
227
+ constructor() {
228
+ super();
229
+ const _ = this;
230
+ }
231
+
232
+ connectedCallback() {
233
+ const _ = this;
234
+ // additional logic or styling can be added here if desired
235
+ }
236
+ }
237
+
238
+ /**
239
+ * @class TabButton
240
+ * a single tab button element
241
+ */
242
+ class TabButton extends HTMLElement {
243
+ constructor() {
244
+ super();
245
+ const _ = this;
246
+ }
247
+
248
+ connectedCallback() {
249
+ const _ = this;
250
+ // note: role and other attributes are handled by the parent
251
+ }
252
+ }
253
+
254
+ /**
255
+ * @class TabPanel
256
+ * a single tab panel element
257
+ */
258
+ class TabPanel extends HTMLElement {
259
+ constructor() {
260
+ super();
261
+ const _ = this;
262
+ }
263
+
264
+ connectedCallback() {
265
+ const _ = this;
266
+ // note: role and other attributes are handled by the parent
267
+ }
268
+ }
269
+
270
+ // define the custom elements
271
+ customElements.define("tab-group", TabGroup);
272
+ customElements.define("tab-list", TabList);
273
+ customElements.define("tab-button", TabButton);
274
+ customElements.define("tab-panel", TabPanel);
275
+
276
+ // export the main component
277
+ export { TabGroup };