@incursa/ui-kit 0.3.1 → 0.3.3

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.
@@ -3,6 +3,7 @@
3
3
 
4
4
  const selectors = {
5
5
  menuToggle: '[data-inc-toggle="menu"]',
6
+ menu: ".inc-dropdown__menu",
6
7
  collapseToggle: '[data-inc-toggle="collapse"]',
7
8
  tabToggle: '[data-inc-toggle="tab"]',
8
9
  modalToggle: '[data-inc-toggle="modal"]',
@@ -15,8 +16,19 @@
15
16
  offcanvas: ".inc-offcanvas",
16
17
  };
17
18
 
19
+ const focusableSelector = [
20
+ 'a[href]',
21
+ 'button:not([disabled])',
22
+ 'input:not([disabled]):not([type="hidden"])',
23
+ 'select:not([disabled])',
24
+ 'textarea:not([disabled])',
25
+ '[tabindex]:not([tabindex="-1"])',
26
+ ].join(", ");
27
+
18
28
  function getTarget(trigger) {
19
- const rawTarget = trigger.getAttribute("data-inc-target") || trigger.getAttribute("href");
29
+ const rawTarget = trigger.getAttribute("data-inc-target")
30
+ || trigger.getAttribute("href")
31
+ || (trigger.getAttribute("aria-controls") ? `#${trigger.getAttribute("aria-controls")}` : "");
20
32
 
21
33
  if (!rawTarget || rawTarget === "#") {
22
34
  return null;
@@ -29,7 +41,77 @@
29
41
  }
30
42
  }
31
43
 
32
- function closeMenu(toggle) {
44
+ function getFocusableElements(container) {
45
+ if (!container) {
46
+ return [];
47
+ }
48
+
49
+ return Array.from(container.querySelectorAll(focusableSelector)).filter((element) => {
50
+ if (!(element instanceof HTMLElement)) {
51
+ return false;
52
+ }
53
+
54
+ if (element.hidden || element.getAttribute("aria-hidden") === "true") {
55
+ return false;
56
+ }
57
+
58
+ return element.tabIndex >= 0;
59
+ });
60
+ }
61
+
62
+ function focusWithin(container, direction = "first") {
63
+ const explicitFocus = container.querySelector("[data-inc-initial-focus]");
64
+
65
+ if (explicitFocus instanceof HTMLElement) {
66
+ explicitFocus.focus();
67
+ return true;
68
+ }
69
+
70
+ const focusable = getFocusableElements(container);
71
+
72
+ if (!focusable.length) {
73
+ if (container instanceof HTMLElement) {
74
+ if (!container.hasAttribute("tabindex")) {
75
+ container.tabIndex = -1;
76
+ }
77
+
78
+ container.focus();
79
+ return true;
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ if (direction === "last") {
86
+ focusable[focusable.length - 1].focus();
87
+ return true;
88
+ }
89
+
90
+ focusable[0].focus();
91
+ return true;
92
+ }
93
+
94
+ function rememberTrigger(target, trigger) {
95
+ if (target instanceof HTMLElement && trigger instanceof HTMLElement) {
96
+ target._incReturnFocus = trigger;
97
+ }
98
+ }
99
+
100
+ function restoreTriggerFocus(target) {
101
+ if (!(target instanceof HTMLElement)) {
102
+ return;
103
+ }
104
+
105
+ const trigger = target._incReturnFocus;
106
+
107
+ if (trigger instanceof HTMLElement && document.contains(trigger)) {
108
+ trigger.focus();
109
+ }
110
+
111
+ delete target._incReturnFocus;
112
+ }
113
+
114
+ function closeMenu(toggle, options = {}) {
33
115
  const menu = getTarget(toggle);
34
116
 
35
117
  if (!menu) {
@@ -38,9 +120,13 @@
38
120
 
39
121
  menu.classList.remove("show");
40
122
  toggle.setAttribute("aria-expanded", "false");
123
+
124
+ if (options.restoreFocus) {
125
+ toggle.focus();
126
+ }
41
127
  }
42
128
 
43
- function openMenu(toggle) {
129
+ function openMenu(toggle, options = {}) {
44
130
  const menu = getTarget(toggle);
45
131
 
46
132
  if (!menu) {
@@ -49,6 +135,16 @@
49
135
 
50
136
  menu.classList.add("show");
51
137
  toggle.setAttribute("aria-expanded", "true");
138
+
139
+ if (options.focus === "first") {
140
+ const items = getMenuItems(menu);
141
+ items[0]?.focus();
142
+ }
143
+
144
+ if (options.focus === "last") {
145
+ const items = getMenuItems(menu);
146
+ items[items.length - 1]?.focus();
147
+ }
52
148
  }
53
149
 
54
150
  function closeAllMenus(exceptToggle) {
@@ -61,6 +157,35 @@
61
157
  });
62
158
  }
63
159
 
160
+ function getMenuItems(menu) {
161
+ return getFocusableElements(menu).filter((item) => item.closest(selectors.menu) === menu);
162
+ }
163
+
164
+ function focusMenuItem(menu, direction) {
165
+ const items = getMenuItems(menu);
166
+
167
+ if (!items.length) {
168
+ return;
169
+ }
170
+
171
+ const activeIndex = items.findIndex((item) => item === document.activeElement);
172
+
173
+ if (direction === "first") {
174
+ items[0].focus();
175
+ return;
176
+ }
177
+
178
+ if (direction === "last") {
179
+ items[items.length - 1].focus();
180
+ return;
181
+ }
182
+
183
+ const delta = direction === "next" ? 1 : -1;
184
+ const startIndex = activeIndex === -1 ? (delta > 0 ? 0 : items.length - 1) : activeIndex;
185
+ const nextIndex = (startIndex + delta + items.length) % items.length;
186
+ items[nextIndex].focus();
187
+ }
188
+
64
189
  function setCollapseState(trigger, target, expanded) {
65
190
  trigger.setAttribute("aria-expanded", expanded ? "true" : "false");
66
191
  trigger.classList.toggle("collapsed", !expanded);
@@ -94,14 +219,22 @@
94
219
  setCollapseState(trigger, target, shouldExpand);
95
220
  }
96
221
 
97
- function activateTab(trigger) {
98
- const listRoot = trigger.closest('[role="tablist"], .inc-tabs-nav');
222
+ function getTabList(trigger) {
223
+ return trigger.closest('[role="tablist"], .inc-tabs-nav');
224
+ }
225
+
226
+ function getTabsForList(listRoot) {
227
+ return Array.from(listRoot.querySelectorAll(selectors.tabToggle));
228
+ }
229
+
230
+ function activateTab(trigger, options = {}) {
231
+ const listRoot = getTabList(trigger);
99
232
 
100
233
  if (!listRoot) {
101
234
  return;
102
235
  }
103
236
 
104
- const tabs = Array.from(listRoot.querySelectorAll(selectors.tabToggle));
237
+ const tabs = getTabsForList(listRoot);
105
238
  const targetPane = getTarget(trigger);
106
239
 
107
240
  if (!targetPane) {
@@ -122,6 +255,39 @@
122
255
  pane.hidden = !isActive;
123
256
  }
124
257
  });
258
+
259
+ if (options.focus && trigger instanceof HTMLElement) {
260
+ trigger.focus();
261
+ }
262
+ }
263
+
264
+ function focusTab(trigger, direction) {
265
+ const listRoot = getTabList(trigger);
266
+
267
+ if (!listRoot) {
268
+ return;
269
+ }
270
+
271
+ const tabs = getTabsForList(listRoot);
272
+ const activeIndex = tabs.findIndex((tab) => tab === trigger);
273
+
274
+ if (activeIndex === -1 || !tabs.length) {
275
+ return;
276
+ }
277
+
278
+ let nextTab = trigger;
279
+
280
+ if (direction === "first") {
281
+ nextTab = tabs[0];
282
+ } else if (direction === "last") {
283
+ nextTab = tabs[tabs.length - 1];
284
+ } else {
285
+ const delta = direction === "next" ? 1 : -1;
286
+ const nextIndex = (activeIndex + delta + tabs.length) % tabs.length;
287
+ nextTab = tabs[nextIndex];
288
+ }
289
+
290
+ activateTab(nextTab, { focus: true });
125
291
  }
126
292
 
127
293
  function syncOverlayBodyState() {
@@ -139,13 +305,15 @@
139
305
  return;
140
306
  }
141
307
 
308
+ rememberTrigger(modal, trigger);
142
309
  modal.hidden = false;
143
310
  modal.classList.add("is-open");
144
311
  modal.setAttribute("aria-hidden", "false");
145
312
  syncOverlayBodyState();
313
+ focusWithin(modal);
146
314
  }
147
315
 
148
- function closeModal(modal) {
316
+ function closeModal(modal, options = {}) {
149
317
  if (!modal) {
150
318
  return;
151
319
  }
@@ -154,6 +322,10 @@
154
322
  modal.setAttribute("aria-hidden", "true");
155
323
  modal.hidden = true;
156
324
  syncOverlayBodyState();
325
+
326
+ if (options.restoreFocus !== false) {
327
+ restoreTriggerFocus(modal);
328
+ }
157
329
  }
158
330
 
159
331
  function getOffcanvasBackdrops(target) {
@@ -171,6 +343,7 @@
171
343
  return;
172
344
  }
173
345
 
346
+ rememberTrigger(panel, trigger);
174
347
  panel.classList.add("is-open");
175
348
  panel.setAttribute("aria-hidden", "false");
176
349
  getOffcanvasBackdrops(panel).forEach((backdrop) => {
@@ -178,9 +351,10 @@
178
351
  backdrop.hidden = false;
179
352
  });
180
353
  syncOverlayBodyState();
354
+ focusWithin(panel);
181
355
  }
182
356
 
183
- function closeOffcanvas(panel) {
357
+ function closeOffcanvas(panel, options = {}) {
184
358
  if (!panel) {
185
359
  return;
186
360
  }
@@ -192,11 +366,61 @@
192
366
  backdrop.hidden = true;
193
367
  });
194
368
  syncOverlayBodyState();
369
+
370
+ if (options.restoreFocus !== false) {
371
+ restoreTriggerFocus(panel);
372
+ }
373
+ }
374
+
375
+ function getTopOpenOverlay() {
376
+ const overlays = [
377
+ ...document.querySelectorAll(`${selectors.modal}.is-open, ${selectors.offcanvas}.is-open`)
378
+ ];
379
+
380
+ return overlays[overlays.length - 1] || null;
381
+ }
382
+
383
+ function trapFocus(event, container) {
384
+ if (event.key !== "Tab") {
385
+ return false;
386
+ }
387
+
388
+ const focusable = getFocusableElements(container);
389
+
390
+ if (!focusable.length) {
391
+ event.preventDefault();
392
+ focusWithin(container);
393
+ return true;
394
+ }
395
+
396
+ const first = focusable[0];
397
+ const last = focusable[focusable.length - 1];
398
+ const active = document.activeElement;
399
+
400
+ if (event.shiftKey && active === first) {
401
+ event.preventDefault();
402
+ last.focus();
403
+ return true;
404
+ }
405
+
406
+ if (!event.shiftKey && active === last) {
407
+ event.preventDefault();
408
+ first.focus();
409
+ return true;
410
+ }
411
+
412
+ return false;
195
413
  }
196
414
 
197
415
  function initializeMenus() {
198
416
  document.querySelectorAll(selectors.menuToggle).forEach((toggle) => {
199
417
  toggle.setAttribute("aria-expanded", "false");
418
+
419
+ const menu = getTarget(toggle);
420
+
421
+ if (menu?.id) {
422
+ toggle.setAttribute("aria-controls", menu.id);
423
+ }
200
424
  });
201
425
  }
202
426
 
@@ -213,14 +437,25 @@
213
437
  }
214
438
 
215
439
  function initializeTabs() {
216
- document.querySelectorAll(selectors.tabToggle).forEach((tab) => {
440
+ document.querySelectorAll(selectors.tabToggle).forEach((tab, index) => {
217
441
  const pane = getTarget(tab);
218
442
  const isActive = tab.classList.contains("active");
219
443
 
444
+ if (!tab.id) {
445
+ tab.id = `inc-tab-${index + 1}`;
446
+ }
447
+
448
+ tab.setAttribute("role", "tab");
220
449
  tab.setAttribute("aria-selected", isActive ? "true" : "false");
221
450
  tab.tabIndex = isActive ? 0 : -1;
222
451
 
223
452
  if (pane) {
453
+ if (pane.id) {
454
+ tab.setAttribute("aria-controls", pane.id);
455
+ }
456
+
457
+ pane.setAttribute("role", "tabpanel");
458
+ pane.setAttribute("aria-labelledby", tab.id);
224
459
  pane.hidden = !isActive;
225
460
  pane.classList.toggle("show", isActive);
226
461
  pane.classList.toggle("active", isActive);
@@ -228,7 +463,7 @@
228
463
  });
229
464
 
230
465
  document.querySelectorAll(selectors.tabPane).forEach((pane) => {
231
- const hasActiveTab = document.querySelector(`${selectors.tabToggle}[href="#${pane.id}"].active, ${selectors.tabToggle}[data-inc-target="#${pane.id}"].active`);
466
+ const hasActiveTab = document.querySelector(`${selectors.tabToggle}[href="#${pane.id}"].active, ${selectors.tabToggle}[data-inc-target="#${pane.id}"].active, ${selectors.tabToggle}[aria-controls="${pane.id}"].active`);
232
467
  pane.hidden = !hasActiveTab;
233
468
  });
234
469
  }
@@ -268,7 +503,10 @@
268
503
  const tabToggle = event.target.closest(selectors.tabToggle);
269
504
 
270
505
  if (tabToggle) {
271
- event.preventDefault();
506
+ if (tabToggle.tagName === "A") {
507
+ event.preventDefault();
508
+ }
509
+
272
510
  activateTab(tabToggle);
273
511
  return;
274
512
  }
@@ -324,9 +562,121 @@
324
562
  });
325
563
 
326
564
  document.addEventListener("keydown", (event) => {
565
+ const menuToggle = event.target.closest(selectors.menuToggle);
566
+ const menu = event.target.closest(selectors.menu);
567
+ const tabToggle = event.target.closest(selectors.tabToggle);
568
+ const openOverlay = getTopOpenOverlay();
569
+
570
+ if (menuToggle) {
571
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
572
+ event.preventDefault();
573
+ closeAllMenus(menuToggle);
574
+ openMenu(menuToggle, { focus: event.key === "ArrowDown" ? "first" : "last" });
575
+ return;
576
+ }
577
+
578
+ if (event.key === "Enter" || event.key === " ") {
579
+ event.preventDefault();
580
+ const isExpanded = menuToggle.getAttribute("aria-expanded") === "true";
581
+
582
+ if (isExpanded) {
583
+ closeMenu(menuToggle);
584
+ } else {
585
+ closeAllMenus(menuToggle);
586
+ openMenu(menuToggle, { focus: "first" });
587
+ }
588
+
589
+ return;
590
+ }
591
+ }
592
+
593
+ if (menu) {
594
+ if (event.key === "ArrowDown") {
595
+ event.preventDefault();
596
+ focusMenuItem(menu, "next");
597
+ return;
598
+ }
599
+
600
+ if (event.key === "ArrowUp") {
601
+ event.preventDefault();
602
+ focusMenuItem(menu, "previous");
603
+ return;
604
+ }
605
+
606
+ if (event.key === "Home") {
607
+ event.preventDefault();
608
+ focusMenuItem(menu, "first");
609
+ return;
610
+ }
611
+
612
+ if (event.key === "End") {
613
+ event.preventDefault();
614
+ focusMenuItem(menu, "last");
615
+ return;
616
+ }
617
+
618
+ if (event.key === "Escape") {
619
+ event.preventDefault();
620
+ const owningToggle = document.querySelector(`${selectors.menuToggle}[aria-controls="${menu.id}"]`);
621
+
622
+ if (owningToggle) {
623
+ closeMenu(owningToggle, { restoreFocus: true });
624
+ }
625
+
626
+ return;
627
+ }
628
+ }
629
+
630
+ if (tabToggle) {
631
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
632
+ event.preventDefault();
633
+ focusTab(tabToggle, "next");
634
+ return;
635
+ }
636
+
637
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
638
+ event.preventDefault();
639
+ focusTab(tabToggle, "previous");
640
+ return;
641
+ }
642
+
643
+ if (event.key === "Home") {
644
+ event.preventDefault();
645
+ focusTab(tabToggle, "first");
646
+ return;
647
+ }
648
+
649
+ if (event.key === "End") {
650
+ event.preventDefault();
651
+ focusTab(tabToggle, "last");
652
+ return;
653
+ }
654
+
655
+ if ((event.key === "Enter" || event.key === " ") && tabToggle.tagName !== "BUTTON") {
656
+ event.preventDefault();
657
+ activateTab(tabToggle, { focus: true });
658
+ return;
659
+ }
660
+ }
661
+
662
+ if (openOverlay && trapFocus(event, openOverlay)) {
663
+ return;
664
+ }
665
+
327
666
  if (event.key === "Escape") {
328
- document.querySelectorAll(`${selectors.modal}.is-open`).forEach((modal) => closeModal(modal));
329
- document.querySelectorAll(`${selectors.offcanvas}.is-open`).forEach((panel) => closeOffcanvas(panel));
667
+ const openModal = document.querySelector(`${selectors.modal}.is-open`);
668
+ const openPanel = document.querySelector(`${selectors.offcanvas}.is-open`);
669
+
670
+ if (openModal) {
671
+ closeModal(openModal);
672
+ return;
673
+ }
674
+
675
+ if (openPanel) {
676
+ closeOffcanvas(openPanel);
677
+ return;
678
+ }
679
+
330
680
  closeAllMenus();
331
681
  }
332
682
  });