@ulu/frontend 0.2.0-beta.9 → 0.3.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.
Files changed (78) hide show
  1. package/README.dev.md +52 -13
  2. package/README.md +3 -1
  3. package/dist/es/core/events.js +36 -25
  4. package/dist/es/core/settings.js +33 -22
  5. package/dist/es/index.js +47 -45
  6. package/dist/es/ui/dialog.d.ts +3 -1
  7. package/dist/es/ui/dialog.d.ts.map +1 -1
  8. package/dist/es/ui/dialog.js +70 -53
  9. package/dist/es/ui/index.d.ts +1 -0
  10. package/dist/es/ui/modal-builder.d.ts +6 -0
  11. package/dist/es/ui/modal-builder.d.ts.map +1 -1
  12. package/dist/es/ui/modal-builder.js +66 -47
  13. package/dist/es/ui/overflow-scroller.js +30 -24
  14. package/dist/es/ui/proxy-click.js +37 -26
  15. package/dist/es/ui/resizer.js +57 -49
  16. package/dist/es/ui/slider.d.ts.map +1 -1
  17. package/dist/es/ui/slider.js +90 -67
  18. package/dist/es/ui/tab-manager.d.ts +145 -0
  19. package/dist/es/ui/tab-manager.d.ts.map +1 -0
  20. package/dist/es/ui/tab-manager.js +155 -0
  21. package/dist/es/ui/tabs.d.ts +5 -5
  22. package/dist/es/ui/tabs.d.ts.map +1 -1
  23. package/dist/es/ui/tabs.js +34 -51
  24. package/dist/es/ui/theme-toggle.js +80 -69
  25. package/dist/es/ui/tooltip.js +53 -44
  26. package/dist/es/utils/dialog.d.ts +14 -0
  27. package/dist/es/utils/dialog.d.ts.map +1 -0
  28. package/dist/es/utils/dialog.js +16 -0
  29. package/dist/es/utils/floating-ui.js +35 -24
  30. package/dist/es/utils/iframe.d.ts +15 -0
  31. package/dist/es/utils/iframe.d.ts.map +1 -0
  32. package/dist/es/utils/iframe.js +33 -0
  33. package/dist/umd/frontend.css +1 -0
  34. package/dist/umd/ulu-frontend.umd.js +40 -47
  35. package/lib/js/exports.md +1 -0
  36. package/lib/js/ui/dialog.js +23 -3
  37. package/lib/js/ui/index.js +4 -0
  38. package/lib/js/ui/modal-builder.js +21 -0
  39. package/lib/js/ui/slider.js +3 -3
  40. package/lib/js/ui/tab-manager.js +324 -0
  41. package/lib/js/ui/tabs.js +33 -92
  42. package/lib/js/utils/dialog.js +29 -0
  43. package/lib/js/utils/iframe.js +59 -0
  44. package/lib/scss/_breakpoint.scss +3 -3
  45. package/lib/scss/_button.scss +3 -3
  46. package/lib/scss/_color.scss +4 -3
  47. package/lib/scss/_element.scss +25 -4
  48. package/lib/scss/_layout.scss +11 -4
  49. package/lib/scss/_selector.scss +2 -1
  50. package/lib/scss/_typography.scss +9 -10
  51. package/lib/scss/_utils.scss +74 -19
  52. package/lib/scss/base/_elements.scss +4 -1
  53. package/lib/scss/components/_accordion.scss +7 -2
  54. package/lib/scss/components/_badge.scss +1 -1
  55. package/lib/scss/components/_basic-hero.scss +1 -1
  56. package/lib/scss/components/_button-group.scss +8 -3
  57. package/lib/scss/components/_button-verbose.scss +2 -2
  58. package/lib/scss/components/_callout.scss +3 -4
  59. package/lib/scss/components/_card-grid.scss +8 -14
  60. package/lib/scss/components/_card.scss +15 -13
  61. package/lib/scss/components/_css-icon.scss +2 -2
  62. package/lib/scss/components/_data-grid.scss +5 -5
  63. package/lib/scss/components/_data-list.scss +270 -0
  64. package/lib/scss/components/_data-table.scss +3 -1
  65. package/lib/scss/components/_flipcard.scss +3 -2
  66. package/lib/scss/components/_index.scss +18 -0
  67. package/lib/scss/components/_menu-stack.scss +1 -1
  68. package/lib/scss/components/_modal.scss +97 -19
  69. package/lib/scss/components/_panel.scss +1 -1
  70. package/lib/scss/components/_popover.scss +9 -6
  71. package/lib/scss/components/_ratio-box.scss +11 -10
  72. package/lib/scss/components/_table-scroller.scss +63 -0
  73. package/lib/scss/components/_tabs.scss +20 -5
  74. package/lib/scss/components/_tagged.scss +59 -0
  75. package/lib/scss/helpers/_utilities.scss +23 -1
  76. package/package.json +28 -35
  77. package/dist/umd/style.css +0 -1
  78. package/lib/js/ui/dialog.todo +0 -3
package/lib/js/exports.md CHANGED
@@ -92,6 +92,7 @@ These exports originate from the `lib/js/ui/` directory.
92
92
  - `sliderInit`
93
93
  - `sliderInitializer`
94
94
  - `sliderSetupSlider`
95
+ - `TabManager`
95
96
  - `tabsInit`
96
97
  - `tabsInitializer`
97
98
  - `tabsInstances`
@@ -6,6 +6,7 @@ import { getUluEventName } from "../core/events.js";
6
6
  import { ComponentInitializer } from "../core/component.js";
7
7
  import { wasClickOutside, preventScroll as setupPreventScroll } from "@ulu/utils/browser/dom.js";
8
8
  import { pauseVideos as pauseYoutubeVideos, prepVideos as prepYoutubeVideos } from "../utils/pause-youtube-video.js";
9
+ import { observeDialogToggle } from "../utils/dialog.js";
9
10
 
10
11
  /**
11
12
  * Base attribute for a dialog
@@ -132,6 +133,7 @@ export function setupDialog(dialog, userOptions) {
132
133
  const options = Object.assign({}, currentDefaults, userOptions);
133
134
  const body = document.body;
134
135
  const { preventScrollShift: preventShift } = options;
136
+ let observerResult = null;
135
137
 
136
138
  // Stores active pointerId for resizer until after the whole pointer event series
137
139
  // is finished which is after the click is complete
@@ -154,13 +156,25 @@ export function setupDialog(dialog, userOptions) {
154
156
  // Cache restore function
155
157
  let restoreScroll;
156
158
 
157
- // Toggle prevent scroll
158
- dialog.addEventListener("toggle", (event) => {
159
- const isOpen = event.newState === "open";
159
+ // FOR THE FUTURE:
160
+ // The toggle event is the preferred method but has poor support in Safari currently.
161
+ // Uncomment and remove the MutationObserver below once support improves.
162
+ // dialog.addEventListener("toggle", (event) => {
163
+ // const isOpen = event.newState === "open";
164
+ // if (isOpen) {
165
+ // restoreScroll = setupPreventScroll({ preventShift });
166
+ // } else if (restoreScroll) {
167
+ // restoreScroll();
168
+ // }
169
+ // });
170
+
171
+ // Workaround for Safari: use MutationObserver to watch for 'open' attribute changes
172
+ observerResult = observeDialogToggle(dialog, (isOpen) => {
160
173
  if (isOpen) {
161
174
  restoreScroll = setupPreventScroll({ preventShift });
162
175
  } else if (restoreScroll) {
163
176
  restoreScroll();
177
+ restoreScroll = null;
164
178
  }
165
179
  });
166
180
  }
@@ -187,6 +201,12 @@ export function setupDialog(dialog, userOptions) {
187
201
  setTimeout(() => { activeResizePointer = null;}, 0);
188
202
  }
189
203
  }
204
+
205
+ return {
206
+ destroy: () => {
207
+ if (observerResult) observerResult.destroy()
208
+ }
209
+ };
190
210
  }
191
211
 
192
212
  /**
@@ -143,6 +143,10 @@ export {
143
143
  Slider,
144
144
  } from "./slider.js";
145
145
 
146
+ export {
147
+ TabManager,
148
+ } from "./tab-manager.js";
149
+
146
150
  export {
147
151
  init as tabsInit,
148
152
  initializer as tabsInitializer,
@@ -10,6 +10,7 @@ import { Resizer } from "./resizer.js";
10
10
  import { baseAttribute, closeAttribute, defaults as dialogDefaults } from "./dialog.js";
11
11
  import { getElement } from "@ulu/utils/browser/dom.js";
12
12
  import { createElementFromHtml } from "@ulu/utils/browser/dom.js";
13
+ import { getSoleIframeLayout } from "../utils/iframe.js";
13
14
 
14
15
  /**
15
16
  * Modal Builder Component Initializer
@@ -47,6 +48,7 @@ export const initializer = new ComponentInitializer({
47
48
  * @property {string|Node} footerElement - Element or selector to use as the footer (will be moved to dialog on creation, used for DOM API)
48
49
  * @property {string|Node} footerHtml - Markup to use in the footer
49
50
  * @property {boolean} debug - Enables debug logging. Defaults to `false`.
51
+ * @property {boolean} autoIframe - Opt-in convenience behavior. If the modal body's sole content is an iframe, it automatically applies layout fixes. If the iframe has static width/height attributes (like YouTube), it retains that aspect ratio responsively. Otherwise, it forces the iframe to fill the modal. Defaults to `false`.
50
52
  * @property {function(object): string} templateCloseIcon - A function that returns the HTML for the close icon.
51
53
  * @property {function(object): string} templateCloseIcon.config - The resolved modal configuration object.
52
54
  * @returns {string} The HTML string for the close icon.
@@ -73,6 +75,7 @@ export const defaults = {
73
75
  size: "default",
74
76
  print: false,
75
77
  noMinHeight: false,
78
+ fullscreenMobile: false,
76
79
  class: "",
77
80
  baseClass: "modal",
78
81
  footerElement: null,
@@ -82,6 +85,7 @@ export const defaults = {
82
85
  classResizerIcon: wrapSettingString("iconClassDragX"),
83
86
  classResizerIconBoth: wrapSettingString("iconClassDragBoth"),
84
87
  debug: false,
88
+ autoIframe: false,
85
89
  templateCloseIcon(config) {
86
90
  const { baseClass, classCloseIcon } = config;
87
91
  return `<span class="${ baseClass }__close-icon ${ classCloseIcon }" aria-hidden="true"></span>`;
@@ -108,6 +112,7 @@ export const defaults = {
108
112
  ...(config.bodyFills ? [`${ baseClass }--body-fills`] : []),
109
113
  ...(config.noBackdrop ? [`${ baseClass }--no-backdrop`] : []),
110
114
  ...(config.noMinHeight ? [`${ baseClass }--no-min-height`] : [] ),
115
+ ...(config.fullscreenMobile ? [`${ baseClass }--fullscreen-mobile`] : [] ),
111
116
  ...(config.class ? [config.class] : []),
112
117
  ];
113
118
  const labelledby = config.title ? `${ id }--title` : config.labelledby;
@@ -212,6 +217,22 @@ export function buildModal(content, options) {
212
217
  }
213
218
  }
214
219
 
220
+ if (config.autoIframe) {
221
+ const layout = getSoleIframeLayout(content);
222
+ if (layout) {
223
+ layout.iframe.classList.add(`${ config.baseClass }__frame-content`);
224
+ if (layout.isStaticSize) {
225
+ modal.classList.add(`${ config.baseClass }--frame-ratio`);
226
+ body.style.aspectRatio = layout.aspectRatio;
227
+ } else {
228
+ modal.classList.add(`${ config.baseClass }--frame-fill`);
229
+ if (layout.fillHeight) {
230
+ body.style.minHeight = layout.fillHeight;
231
+ }
232
+ }
233
+ }
234
+ }
235
+
215
236
  let resizer;
216
237
  const resizablePositions = ["left", "right", "center"];
217
238
  const isCenter = position === "center";
@@ -32,7 +32,6 @@
32
32
 
33
33
  import { ComponentInitializer } from "../core/component.js";
34
34
  import { wrapSettingString } from "../core/settings.js";
35
- import maintain from 'ally.js/maintain/_maintain';
36
35
  import { hasRequiredProps } from '@ulu/utils/object.js';
37
36
  import { trimWhitespace } from "@ulu/utils/string.js";
38
37
  import { debounce } from "@ulu/utils/performance.js";
@@ -392,8 +391,9 @@ export class Slider {
392
391
  }
393
392
 
394
393
  // Make all slide interactive elements inert
395
- const lockInteractives = maintain.disabled({ context: this.elements.track });
394
+ this.elements.track.inert = true;
396
395
  this.transitioning = true;
396
+
397
397
  // Set classes first just feels better
398
398
  if (old) old.navButton.classList.remove(activeClass);
399
399
  slide.navButton.classList.add(activeClass);
@@ -403,8 +403,8 @@ export class Slider {
403
403
  this.index = index;
404
404
  this.slide = slide;
405
405
  this.transitioning = false;
406
+ this.elements.track.inert = false;
406
407
  elements.container.classList.remove(transitionClass);
407
- lockInteractives.disengage();
408
408
  if (!isInit) {
409
409
  slide.element.focus();
410
410
  this.emit("goto", [event, index, slide]);
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @module ui/tab-manager
3
+ */
4
+
5
+ import { ensureId } from "../utils/id.js";
6
+ import { getCoreEventName } from "../core/events.js";
7
+
8
+ /**
9
+ * @typedef {Object} TabManagerOptions
10
+ * @property {String|null} [orientation=null] - "horizontal"|"vertical", auto-detected if omitted.
11
+ * @property {Number} [initialIndex=0] - Index to activate on load.
12
+ * @property {Boolean} [allArrows=false] - Allow all arrow keys to navigate regardless of orientation.
13
+ * @property {Boolean} [openByUrlHash=false] - Activate tab based on URL hash on initialization.
14
+ * @property {Boolean} [setUrlHash=false] - Update URL hash when a new tab is activated.
15
+ * @property {Boolean} [equalHeights=false] - Automatically match the height of all panels.
16
+ * @property {Function|null} [onReady=null] - Callback fired after initialization: (instance) => {}
17
+ * @property {Function|null} [onChange=null] - Callback fired when tab changes: (active, previous) => {}
18
+ */
19
+
20
+ /**
21
+ * Class for managing Aria tabs
22
+ * - Designed to be minimal and lightweight but cover all traditional needs
23
+ * - Designed for static / traditional webpages (not SPA)
24
+ * - Separated from tabs.js so it can be used by itself as needed (tree-shaking)
25
+ */
26
+ export class TabManager {
27
+ /**
28
+ * Default options for TabManager.
29
+ * @type {TabManagerOptions}
30
+ */
31
+ static defaults = {
32
+ orientation: null,
33
+ initialIndex: 0,
34
+ allArrows: false,
35
+ openByUrlHash: false,
36
+ setUrlHash: false,
37
+ equalHeights: false,
38
+ onReady: null,
39
+ onChange: null
40
+ };
41
+
42
+ /**
43
+ * @param {HTMLElement} tablistElement - The element with role="tablist"
44
+ * @param {Partial<TabManagerOptions>} [options] - Configuration options.
45
+ */
46
+ constructor(tablistElement, options = {}) {
47
+ this.tablist = tablistElement;
48
+ this.options = { ...TabManager.defaults, ...options };
49
+ this.tabs = Array.from(this.tablist.children);
50
+
51
+ // Discover panels via `aria-controls`
52
+ this.panels = this.tabs.map(tab => {
53
+ const controlsId = tab.getAttribute('aria-controls');
54
+ return controlsId ? document.getElementById(controlsId) : null;
55
+ }).filter(Boolean); // Ensure no nulls in panels array
56
+
57
+ this.currentIndex = -1;
58
+
59
+ // Bind methods
60
+ this.handleKeydown = this.handleKeydown.bind(this);
61
+ this.handleClick = this.handleClick.bind(this);
62
+
63
+ // Bind the new height update method for the event listener
64
+ this.updatePanelHeights = this.updatePanelHeights.bind(this);
65
+
66
+ if (this.tabs.length === 0 || this.tabs.length !== this.panels.length) {
67
+ console.warn("TabManager: Tab/Panel count mismatch. Check aria-controls.", { tabs: this.tabs, panels: this.panels });
68
+ return;
69
+ }
70
+
71
+ this.orientation = this.options.orientation || this.tablist.getAttribute("aria-orientation") || "horizontal";
72
+
73
+ this.setupAttributes();
74
+ this.attachListeners();
75
+
76
+ // Handle initial state from URL hash if configured
77
+ let startingIndex = this.options.initialIndex;
78
+ if (this.options.openByUrlHash) {
79
+ const hash = window.location.hash.substring(1);
80
+ const hashIndex = this.tabs.findIndex(tab => tab.id === hash);
81
+ if (hashIndex > -1) {
82
+ startingIndex = hashIndex;
83
+ }
84
+ }
85
+
86
+ this.activate(startingIndex, false);
87
+
88
+ // Handle equal heights on init and on resize
89
+ if (this.options.equalHeights) {
90
+ this.updatePanelHeights();
91
+ document.addEventListener(getCoreEventName('pageResized'), this.updatePanelHeights);
92
+ }
93
+
94
+ if (this.options.onReady) {
95
+ this.options.onReady(this);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Sets the necessary ARIA attributes and initial states for tabs and panels.
101
+ * @private
102
+ */
103
+ setupAttributes() {
104
+ this.tablist.setAttribute("role", "tablist");
105
+
106
+ this.tabs.forEach((tab, index) => {
107
+ const panel = this.panels[index];
108
+
109
+ // Ensure elements have IDs for ARIA attributes
110
+ ensureId(tab);
111
+ ensureId(panel);
112
+
113
+ // Set ARIA Roles & Relationships
114
+ tab.setAttribute("role", "tab");
115
+ // This is now the primary link, but we still ensure it's set.
116
+ if (!tab.hasAttribute('aria-controls')) {
117
+ tab.setAttribute("aria-controls", panel.id);
118
+ }
119
+
120
+ panel.setAttribute("role", "tabpanel");
121
+ panel.setAttribute("aria-labelledby", tab.id);
122
+
123
+ // Initial hidden state
124
+ panel.hidden = true;
125
+ tab.setAttribute("tabindex", "-1");
126
+ tab.setAttribute("aria-selected", "false");
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Attaches click and keydown event listeners to each tab.
132
+ * @private
133
+ */
134
+ attachListeners() {
135
+ this.tabs.forEach(tab => {
136
+ tab.addEventListener("click", this.handleClick);
137
+ tab.addEventListener("keydown", this.handleKeydown);
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Handles click events on tabs, activating the corresponding panel.
143
+ * @param {MouseEvent} e - The click event.
144
+ * @private
145
+ */
146
+ handleClick(e) {
147
+ const index = this.tabs.indexOf(e.currentTarget);
148
+ this.activate(index);
149
+ }
150
+
151
+ /**
152
+ * Handles keyboard navigation (arrows, Home, End) on the tab list.
153
+ * @param {KeyboardEvent} e - The keydown event.
154
+ * @private
155
+ */
156
+ handleKeydown(e) {
157
+ const index = this.tabs.indexOf(e.currentTarget);
158
+ let nextIndex = null;
159
+ const isVert = this.orientation === "vertical";
160
+ const allArrows = this.options.allArrows;
161
+ const isRtl = (this.tablist.dir === 'rtl' || document.dir === 'rtl') && this.tablist.dir !== 'ltr';
162
+
163
+ const keyNext = isRtl ? 'ArrowLeft' : 'ArrowRight';
164
+ const keyPrev = isRtl ? 'ArrowRight' : 'ArrowLeft';
165
+
166
+ // Vertical movement
167
+ if (e.key === "ArrowDown") {
168
+ if (isVert || allArrows) nextIndex = (index + 1) % this.tabs.length;
169
+ } else if (e.key === "ArrowUp") {
170
+ if (isVert || allArrows) nextIndex = (index - 1 + this.tabs.length) % this.tabs.length;
171
+ // Horizontal movement
172
+ } else if (e.key === keyNext) {
173
+ if (!isVert || allArrows) nextIndex = (index + 1) % this.tabs.length;
174
+ } else if (e.key === keyPrev) {
175
+ if (!isVert || allArrows) nextIndex = (index - 1 + this.tabs.length) % this.tabs.length;
176
+ // Other keys
177
+ } else if (e.key === "Home") {
178
+ nextIndex = 0;
179
+ } else if (e.key === "End") {
180
+ nextIndex = this.tabs.length - 1;
181
+ }
182
+
183
+ if (nextIndex !== null) {
184
+ e.preventDefault();
185
+ this.activate(nextIndex);
186
+ this.tabs[nextIndex].focus();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Activates a tab. Can be called with an index or a tab ID string.
192
+ * @param {Number|String} indexOrId - The index or ID of the tab to activate.
193
+ * @param {Boolean} [triggerActions=true] - If false, will not fire onChange or set URL hash.
194
+ */
195
+ activate(indexOrId, triggerActions = true) {
196
+ let index = -1;
197
+ if (typeof indexOrId === "string") {
198
+ index = this.tabs.findIndex(tab => tab.id === indexOrId);
199
+ } else {
200
+ index = indexOrId;
201
+ }
202
+
203
+ if (index < 0 || index >= this.tabs.length) return;
204
+ if (this.currentIndex === index) return;
205
+
206
+ const prevIndex = this.currentIndex;
207
+ const prevTab = prevIndex > -1 ? this.tabs[prevIndex] : null;
208
+ const prevPanel = prevIndex > -1 ? this.panels[prevIndex] : null;
209
+
210
+ // Deactivate Current
211
+ if (prevTab) {
212
+ prevTab.setAttribute("aria-selected", "false");
213
+ prevTab.setAttribute("tabindex", "-1");
214
+ prevPanel.hidden = true;
215
+ }
216
+
217
+ // Activate New
218
+ const tab = this.tabs[index];
219
+ const panel = this.panels[index];
220
+
221
+ tab.setAttribute("aria-selected", "true");
222
+ tab.setAttribute("tabindex", "0");
223
+ panel.hidden = false;
224
+
225
+ this.currentIndex = index;
226
+
227
+ // Update URL hash if configured and not the initial silent activation
228
+ if (triggerActions && this.options.setUrlHash && window.history) {
229
+ window.history.replaceState(null, "", `#${ tab.id }`);
230
+ }
231
+
232
+ // Fire onChange callback
233
+ if (triggerActions && this.options.onChange) {
234
+ this.options.onChange(
235
+ { index, tab, panel },
236
+ { index: prevIndex, tab: prevTab, panel: prevPanel }
237
+ );
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Public method to activate a tab by its ID.
243
+ * @param {String} id - The ID of the tab element to activate.
244
+ */
245
+ activateById(id) {
246
+ this.activate(id, true);
247
+ }
248
+
249
+ /**
250
+ * Calculates and applies equal heights to all panels.
251
+ * Waits for images within panels to load before calculating.
252
+ */
253
+ updatePanelHeights() {
254
+ if (!this.panels || this.panels.length === 0) return;
255
+
256
+ const parent = this.panels[0].parentElement;
257
+ if (!parent) return;
258
+ const images = [ ...parent.querySelectorAll("img") ];
259
+
260
+ const imagePromise = (image) => new Promise((resolve) => {
261
+ if (image.complete) return resolve(image);
262
+ image.onload = () => resolve(image);
263
+ image.onerror = () => resolve(image); // Resolve on error so it doesn't block
264
+ });
265
+
266
+ const imagePromises = images.map(imagePromise);
267
+
268
+ Promise.all(imagePromises).then(() => {
269
+ // Reset heights to auto before measuring to get natural height
270
+ this.panels.forEach(panel => {
271
+ panel.style.minHeight = '';
272
+ });
273
+
274
+ const heights = this.panels.map(panel => {
275
+ const wasHidden = panel.hidden;
276
+ panel.hidden = false;
277
+ const panelHeight = panel.offsetHeight;
278
+ panel.hidden = wasHidden;
279
+ return panelHeight;
280
+ });
281
+
282
+ const max = Math.max(...heights);
283
+ if (max > 0) {
284
+ this.panels.forEach(panel => {
285
+ panel.style.minHeight = `${ max }px`;
286
+ });
287
+ }
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Removes event listeners, cleans up ARIA attributes, and resets the DOM to its pre-initialized state.
293
+ */
294
+ destroy() {
295
+ this.tabs.forEach(tab => {
296
+ tab.removeEventListener("click", this.handleClick);
297
+ tab.removeEventListener("keydown", this.handleKeydown);
298
+ });
299
+ if (this.options.equalHeights) {
300
+ document.removeEventListener(getCoreEventName('pageResized'), this.updatePanelHeights);
301
+ }
302
+
303
+ this.tablist.removeAttribute("role");
304
+
305
+ this.tabs.forEach(tab => {
306
+ tab.removeAttribute("role");
307
+ tab.removeAttribute("aria-selected");
308
+ tab.removeAttribute("tabindex");
309
+ });
310
+
311
+ this.panels.forEach(panel => {
312
+ panel.removeAttribute("role");
313
+ panel.removeAttribute("aria-labelledby");
314
+ panel.hidden = false;
315
+ panel.style.minHeight = '';
316
+ });
317
+
318
+ this.tablist = null;
319
+ this.tabs = [];
320
+ this.panels = [];
321
+ this.options = {};
322
+ this.currentIndex = -1;
323
+ }
324
+ }
package/lib/js/ui/tabs.js CHANGED
@@ -2,14 +2,9 @@
2
2
  * @module ui/tabs
3
3
  */
4
4
 
5
- // TODO:
6
- // - For Vertical tabs we should be updating the orientation when on mobile.
7
- // Currently using all arrows so that the interface works in both
8
- // orientations when vertical. Leaving that behavior for now but maybe consider
9
- // setting this up to destroy tab interface when ui layout changes?
10
-
11
- import AriaTablist from "aria-tablist";
5
+ import { TabManager } from "./tab-manager.js";
12
6
  import { ComponentInitializer } from "../core/component.js";
7
+ import { ensureId } from "../utils/id.js";
13
8
 
14
9
  /**
15
10
  * Array of current tab instances (exported if you need to interact with them)
@@ -38,11 +33,8 @@ export function init() {
38
33
  initialize();
39
34
  }
40
35
  });
41
-
42
- // Run this on page load, optionally exported for use when page is running
43
- instances.forEach(openByCurrentHash);
44
36
  };
45
-
37
+
46
38
  if (document.readyState === "complete") {
47
39
  initial();
48
40
  } else {
@@ -51,97 +43,46 @@ export function init() {
51
43
  }
52
44
 
53
45
  /**
54
- *
55
- * @param {Node} element Tablist Element
56
- * @param {Node} options Options to set as defaults (can be overridden by element dataset options)
57
- * @return {Object} Instance object
46
+ * Setup a new TabManager instance
47
+ * @param {HTMLElement} element Tablist Element
48
+ * @param {object} options Options to set as defaults
49
+ * @return {object} Instance object
58
50
  */
59
51
  export function setup(element, options = {}) {
60
- const config = Object.assign({}, options);
52
+ const config = { ...options };
61
53
 
54
+ // Backwards compatibility, `vertical:true` implies `allArrows:true`
62
55
  if (config.vertical) {
63
56
  config.allArrows = true;
64
57
  }
65
58
 
66
- // Need to render the markup before checking height
67
- // - used to wait until images had loaded
68
- const instance = { element, options };
69
- instance.ariaTablist = AriaTablist(element, {
70
- onOpen(...args) {
71
- args.unshift(instance);
72
- handleOpen.apply(null, args);
73
- },
74
- ...config
75
- });
76
- instances.push(instance);
77
-
78
- if (config.equalHeights) {
79
- setHeights(element);
59
+ // Backwards compatibility, `openByUrlHash:true` implies `setUrlHash:true`
60
+ // to replicate the behavior of the old aria-tablist library.
61
+ if (config.openByUrlHash) {
62
+ config.setUrlHash = true;
80
63
  }
81
-
82
- return instance;
83
- }
84
64
 
85
- /**
86
- * Opens the a tabpanel if it matches current hash (used in initial init)
87
- */
88
- function openByCurrentHash({ options, ariaTablist }) {
89
- if (options.openByUrlHash) {
90
- const { hash } = window.location;
91
- if (hash && hash.length > 1) {
92
- const possibleId = hash.substring(1);
93
- ariaTablist.tabs.forEach(tab => {
94
- if (possibleId === tab.id) {
95
- ariaTablist.open(tab);
96
- }
97
- });
65
+ // Backwards compatibility, ensure `aria-controls` is present,
66
+ // generating it from the legacy `aria-labelledby` pattern if needed.
67
+ const tabs = [...element.children];
68
+
69
+ tabs.forEach((tab) => {
70
+ if (!tab.hasAttribute('aria-controls')) {
71
+ // Find the panel using the old association method
72
+ const panel = document.querySelector(`[aria-labelledby="${tab.id}"]`);
73
+ if (panel) {
74
+ ensureId(panel);
75
+ tab.setAttribute('aria-controls', panel.id);
76
+ }
98
77
  }
99
- }
100
- }
78
+ });
101
79
 
102
- /**
103
- * Responsible for setting hash on open if option is set
104
- */
105
- function handleOpen({ options }, panel, tab) {
106
- if (options.openByUrlHash && window.history) {
107
- window.history.replaceState(null, "", `#${ tab.id }`);
108
- }
109
- }
80
+ const instance = { element, options };
110
81
 
111
- /**
112
- * Responsible for creating equal height tab panels
113
- */
114
- function setHeights(element) {
115
- const tabs = [ ...element.children];
116
- const panels = tabs.map(n => document.querySelector(`[aria-labelledby="${ n.id }"]`));
117
- const parent = panels[0].parentElement;
118
- const images = [ ...parent.querySelectorAll("img") ];
119
- const imagePromises = images.map(image => imagePromise(image));
120
- function imagePromise(image) {
121
- return new Promise((resolve) => {
122
- if (image.complete) {
123
- resolve(image);
124
- } else {
125
- image.onload = resolve;
126
- // Errors should also resolve so that height matching continues
127
- image.onerror = resolve;
128
- }
129
- });
130
- }
131
- // Run after images are loaded, or if no images it will resolve and run
132
- Promise.all(imagePromises).then(() => {
133
- const heights = panels.map(panel => {
134
- let panelHeight = panel.offsetHeight;
135
- if (panel.hidden) {
136
- panel.hidden = false;
137
- panelHeight = panel.offsetHeight;
138
- // This explicity needs "hidden" for aria-tablist (it checks this string value)
139
- // Will break the initial window push state when using openWithUrlHash
140
- panel.setAttribute("hidden", "hidden");
141
- }
142
- return panelHeight;
143
- });
144
- const max = Math.max(...heights);
145
- panels.forEach(panel => panel.style.minHeight = `${ max }px`);
146
- });
82
+ // Instantiate the new TabManager. It will find its own tabs/panels
83
+ // and handle all URL hash and equal heights logic internally now
84
+ instance.tabManager = new TabManager(element, config);
85
+ instances.push(instance);
86
+
87
+ return instance;
147
88
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Utility functions for dialogs
3
+ * @module utils/dialog
4
+ */
5
+
6
+ /**
7
+ * Workaround for poor Safari support of the dialog 'toggle' event.
8
+ * Watches for changes to the 'open' attribute and fires a callback.
9
+ *
10
+ * @param {HTMLDialogElement} dialog The dialog element to observe
11
+ * @param {Function} callback Function to call when the open state changes. Receives boolean indicating open state.
12
+ * @returns {Object} Object with a destroy method to disconnect the observer
13
+ */
14
+ export function observeDialogToggle(dialog, callback) {
15
+ const observer = new MutationObserver((mutations) => {
16
+ mutations.forEach((mutation) => {
17
+ if (mutation.attributeName === "open") {
18
+ const isOpen = dialog.hasAttribute("open");
19
+ callback(isOpen);
20
+ }
21
+ });
22
+ });
23
+
24
+ observer.observe(dialog, { attributes: true, attributeFilter: ["open"] });
25
+
26
+ return {
27
+ destroy: () => observer.disconnect()
28
+ };
29
+ }