@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.
- package/README.dev.md +52 -13
- package/README.md +3 -1
- package/dist/es/core/events.js +36 -25
- package/dist/es/core/settings.js +33 -22
- package/dist/es/index.js +47 -45
- package/dist/es/ui/dialog.d.ts +3 -1
- package/dist/es/ui/dialog.d.ts.map +1 -1
- package/dist/es/ui/dialog.js +70 -53
- package/dist/es/ui/index.d.ts +1 -0
- package/dist/es/ui/modal-builder.d.ts +6 -0
- package/dist/es/ui/modal-builder.d.ts.map +1 -1
- package/dist/es/ui/modal-builder.js +66 -47
- package/dist/es/ui/overflow-scroller.js +30 -24
- package/dist/es/ui/proxy-click.js +37 -26
- package/dist/es/ui/resizer.js +57 -49
- package/dist/es/ui/slider.d.ts.map +1 -1
- package/dist/es/ui/slider.js +90 -67
- package/dist/es/ui/tab-manager.d.ts +145 -0
- package/dist/es/ui/tab-manager.d.ts.map +1 -0
- package/dist/es/ui/tab-manager.js +155 -0
- package/dist/es/ui/tabs.d.ts +5 -5
- package/dist/es/ui/tabs.d.ts.map +1 -1
- package/dist/es/ui/tabs.js +34 -51
- package/dist/es/ui/theme-toggle.js +80 -69
- package/dist/es/ui/tooltip.js +53 -44
- package/dist/es/utils/dialog.d.ts +14 -0
- package/dist/es/utils/dialog.d.ts.map +1 -0
- package/dist/es/utils/dialog.js +16 -0
- package/dist/es/utils/floating-ui.js +35 -24
- package/dist/es/utils/iframe.d.ts +15 -0
- package/dist/es/utils/iframe.d.ts.map +1 -0
- package/dist/es/utils/iframe.js +33 -0
- package/dist/umd/frontend.css +1 -0
- package/dist/umd/ulu-frontend.umd.js +40 -47
- package/lib/js/exports.md +1 -0
- package/lib/js/ui/dialog.js +23 -3
- package/lib/js/ui/index.js +4 -0
- package/lib/js/ui/modal-builder.js +21 -0
- package/lib/js/ui/slider.js +3 -3
- package/lib/js/ui/tab-manager.js +324 -0
- package/lib/js/ui/tabs.js +33 -92
- package/lib/js/utils/dialog.js +29 -0
- package/lib/js/utils/iframe.js +59 -0
- package/lib/scss/_breakpoint.scss +3 -3
- package/lib/scss/_button.scss +3 -3
- package/lib/scss/_color.scss +4 -3
- package/lib/scss/_element.scss +25 -4
- package/lib/scss/_layout.scss +11 -4
- package/lib/scss/_selector.scss +2 -1
- package/lib/scss/_typography.scss +9 -10
- package/lib/scss/_utils.scss +74 -19
- package/lib/scss/base/_elements.scss +4 -1
- package/lib/scss/components/_accordion.scss +7 -2
- package/lib/scss/components/_badge.scss +1 -1
- package/lib/scss/components/_basic-hero.scss +1 -1
- package/lib/scss/components/_button-group.scss +8 -3
- package/lib/scss/components/_button-verbose.scss +2 -2
- package/lib/scss/components/_callout.scss +3 -4
- package/lib/scss/components/_card-grid.scss +8 -14
- package/lib/scss/components/_card.scss +15 -13
- package/lib/scss/components/_css-icon.scss +2 -2
- package/lib/scss/components/_data-grid.scss +5 -5
- package/lib/scss/components/_data-list.scss +270 -0
- package/lib/scss/components/_data-table.scss +3 -1
- package/lib/scss/components/_flipcard.scss +3 -2
- package/lib/scss/components/_index.scss +18 -0
- package/lib/scss/components/_menu-stack.scss +1 -1
- package/lib/scss/components/_modal.scss +97 -19
- package/lib/scss/components/_panel.scss +1 -1
- package/lib/scss/components/_popover.scss +9 -6
- package/lib/scss/components/_ratio-box.scss +11 -10
- package/lib/scss/components/_table-scroller.scss +63 -0
- package/lib/scss/components/_tabs.scss +20 -5
- package/lib/scss/components/_tagged.scss +59 -0
- package/lib/scss/helpers/_utilities.scss +23 -1
- package/package.json +28 -35
- package/dist/umd/style.css +0 -1
- package/lib/js/ui/dialog.todo +0 -3
package/lib/js/exports.md
CHANGED
package/lib/js/ui/dialog.js
CHANGED
|
@@ -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
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
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
|
/**
|
package/lib/js/ui/index.js
CHANGED
|
@@ -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";
|
package/lib/js/ui/slider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
56
|
-
* @param {
|
|
57
|
-
* @return {
|
|
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 =
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|