@neuravision/construct 2.0.3 → 2.1.2
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/CHANGELOG.md +318 -0
- package/README.md +158 -149
- package/behaviors/combobox.js +486 -0
- package/behaviors/drawer.js +18 -0
- package/behaviors/dropdown.js +310 -0
- package/behaviors/index.d.ts +335 -0
- package/behaviors/index.js +9 -0
- package/behaviors/internal/dialog-controller.js +421 -0
- package/behaviors/internal/dom.js +408 -0
- package/behaviors/internal/overlay.js +241 -0
- package/behaviors/internal/popup-layer.js +190 -0
- package/behaviors/modal.js +17 -0
- package/behaviors/popover.js +278 -0
- package/behaviors/select-menu.js +344 -0
- package/behaviors/tabs.js +241 -0
- package/behaviors/toggle-group.js +266 -0
- package/behaviors/tooltip.js +257 -0
- package/components/README.md +319 -83
- package/components/_shared.css +22 -10
- package/components/_shell-shared.css +20 -4
- package/components/accordion.css +8 -0
- package/components/app-shell-v2.css +28 -14
- package/components/app-shell.css +30 -14
- package/components/badge.css +15 -0
- package/components/banner.css +32 -1
- package/components/button.css +103 -15
- package/components/card.css +67 -8
- package/components/chart.css +26 -0
- package/components/checkbox.css +35 -2
- package/components/chip.css +30 -2
- package/components/combobox.css +21 -0
- package/components/datepicker.css +58 -9
- package/components/datum.css +277 -0
- package/components/dropdown.css +23 -3
- package/components/field.css +6 -4
- package/components/file-upload.css +1 -1
- package/components/index.css +5 -0
- package/components/list.css +28 -2
- package/components/meter.css +182 -0
- package/components/navbar.css +102 -4
- package/components/pagination.css +18 -0
- package/components/progress-bar.css +22 -1
- package/components/select-menu.css +8 -0
- package/components/select.css +8 -0
- package/components/sidebar.css +15 -2
- package/components/skip-link.css +6 -2
- package/components/slider.css +7 -1
- package/components/status.css +107 -0
- package/components/switch.css +66 -5
- package/components/table.css +53 -4
- package/components/tabs.css +55 -3
- package/components/toast.css +10 -1
- package/components/toggle-group.css +16 -5
- package/components/toolbar.css +17 -1
- package/components/tooltip.css +4 -0
- package/components/tree.css +30 -0
- package/docs/architecture.md +79 -0
- package/docs/behaviors.md +122 -0
- package/docs/fonts.md +70 -0
- package/docs/governance.md +85 -0
- package/docs/guidelines.md +72 -25
- package/examples/react/README.md +20 -0
- package/examples/react/SettingsModal.jsx +66 -0
- package/examples/react/construct.css +5 -0
- package/fonts.css +10 -0
- package/foundations.css +14 -1
- package/package.json +76 -21
- package/schemas/components.schema.json +122 -0
- package/schemas/examples/theme.example.json +27 -0
- package/schemas/primitives.schema.json +135 -0
- package/schemas/semantic.schema.json +224 -0
- package/schemas/theme.schema.json +63 -0
- package/scripts/build-tokens.mjs +1499 -0
- package/scripts/construct-theme.mjs +43 -0
- package/scripts/validate-tokens.mjs +25 -0
- package/standalone/accordion.css +3 -0
- package/standalone/alert.css +3 -0
- package/standalone/app-shell-v2.css +4 -0
- package/standalone/app-shell.css +4 -0
- package/standalone/avatar.css +3 -0
- package/standalone/badge.css +3 -0
- package/standalone/banner.css +3 -0
- package/standalone/breadcrumbs.css +3 -0
- package/standalone/button.css +3 -0
- package/standalone/card.css +3 -0
- package/standalone/chart.css +3 -0
- package/standalone/checkbox.css +3 -0
- package/standalone/chip.css +3 -0
- package/standalone/combobox.css +3 -0
- package/standalone/core.css +4 -0
- package/standalone/data-table.css +3 -0
- package/standalone/datepicker.css +3 -0
- package/standalone/datum.css +3 -0
- package/standalone/divider.css +3 -0
- package/standalone/drawer.css +3 -0
- package/standalone/dropdown.css +3 -0
- package/standalone/empty-state.css +3 -0
- package/standalone/field.css +3 -0
- package/standalone/file-upload.css +3 -0
- package/standalone/icon.css +3 -0
- package/standalone/input.css +3 -0
- package/standalone/list.css +3 -0
- package/standalone/meter.css +3 -0
- package/standalone/modal.css +3 -0
- package/standalone/navbar.css +3 -0
- package/standalone/pagination.css +3 -0
- package/standalone/popover.css +3 -0
- package/standalone/progress-bar.css +3 -0
- package/standalone/select-menu.css +3 -0
- package/standalone/select.css +3 -0
- package/standalone/sidebar.css +3 -0
- package/standalone/skeleton.css +3 -0
- package/standalone/skip-link.css +3 -0
- package/standalone/slider.css +3 -0
- package/standalone/spinner.css +3 -0
- package/standalone/status.css +3 -0
- package/standalone/switch.css +3 -0
- package/standalone/table.css +3 -0
- package/standalone/tabs.css +3 -0
- package/standalone/textarea.css +3 -0
- package/standalone/toast.css +3 -0
- package/standalone/toggle-group.css +3 -0
- package/standalone/toolbar.css +3 -0
- package/standalone/tooltip.css +3 -0
- package/standalone/tree.css +3 -0
- package/themes/README.md +37 -0
- package/themes/walnut.theme.json +106 -0
- package/tokens/README.md +135 -123
- package/tokens/components.json +64 -0
- package/tokens/primitives.json +268 -0
- package/tokens/semantic.dark.json +94 -0
- package/tokens/semantic.high-contrast-dark.json +94 -0
- package/tokens/semantic.high-contrast.json +94 -0
- package/tokens/semantic.light.json +179 -0
- package/tokens/tokens.css +960 -58
- package/tokens/tokens.js +729 -120
- package/tokens/tokens.json +1158 -116
- package/tokens/tokens.ts +729 -120
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertDescendants,
|
|
3
|
+
assertElement,
|
|
4
|
+
assertElements,
|
|
5
|
+
assertFunction,
|
|
6
|
+
assertSameDocument,
|
|
7
|
+
assertUniqueElements,
|
|
8
|
+
boundaryEnabledIndex,
|
|
9
|
+
createAttributeStore,
|
|
10
|
+
createControllerLifecycle,
|
|
11
|
+
createDisposables,
|
|
12
|
+
createTypeahead,
|
|
13
|
+
dispatch,
|
|
14
|
+
ensureId,
|
|
15
|
+
focusElement,
|
|
16
|
+
getTextLabel,
|
|
17
|
+
isDisabled,
|
|
18
|
+
nextEnabledIndex,
|
|
19
|
+
} from './internal/dom.js';
|
|
20
|
+
import { createPopupLayer } from './internal/popup-layer.js';
|
|
21
|
+
|
|
22
|
+
const controllers = new WeakMap();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a single-value, select-only combobox controller backed by a listbox.
|
|
26
|
+
* Keyboard focus remains on the trigger while `aria-activedescendant` identifies
|
|
27
|
+
* the active option.
|
|
28
|
+
*/
|
|
29
|
+
export function createSelectMenuController(options) {
|
|
30
|
+
if (!options || typeof options !== 'object') {
|
|
31
|
+
throw new TypeError('Select menu controller options are required.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const trigger = assertElement(options.trigger, 'options.trigger');
|
|
35
|
+
const existing = controllers.get(trigger);
|
|
36
|
+
if (existing) return existing;
|
|
37
|
+
const listbox = assertElement(options.listbox, 'options.listbox');
|
|
38
|
+
const root = assertElement(options.root ?? listbox.closest('.ct-select-menu') ?? listbox, 'options.root');
|
|
39
|
+
const optionElements = options.options == null
|
|
40
|
+
? [...listbox.querySelectorAll('[role="option"], [data-ct-option]')]
|
|
41
|
+
: assertElements(options.options, 'options.options');
|
|
42
|
+
if (!optionElements.length) throw new TypeError('Select menu controller requires at least one option.');
|
|
43
|
+
if (trigger.ownerDocument !== listbox.ownerDocument || trigger.ownerDocument !== root.ownerDocument) {
|
|
44
|
+
throw new TypeError('Select menu elements must belong to the same document.');
|
|
45
|
+
}
|
|
46
|
+
assertUniqueElements(optionElements, 'options.options');
|
|
47
|
+
assertSameDocument(optionElements, trigger.ownerDocument, 'options.options');
|
|
48
|
+
assertDescendants(optionElements, listbox, 'options.options');
|
|
49
|
+
assertFunction(options.getOptionLabel, 'options.getOptionLabel');
|
|
50
|
+
assertFunction(options.getOptionValue, 'options.getOptionValue');
|
|
51
|
+
assertFunction(options.onValueChange, 'options.onValueChange');
|
|
52
|
+
assertFunction(options.onOpen, 'options.onOpen');
|
|
53
|
+
assertFunction(options.onClose, 'options.onClose');
|
|
54
|
+
|
|
55
|
+
const attributes = createAttributeStore();
|
|
56
|
+
const lifecycle = createControllerLifecycle('Select menu');
|
|
57
|
+
const disposables = createDisposables();
|
|
58
|
+
const typeahead = createTypeahead({ timeout: options.typeaheadTimeout });
|
|
59
|
+
const getLabel = options.getOptionLabel ?? getTextLabel;
|
|
60
|
+
const getValue = options.getOptionValue ?? ((option, index) =>
|
|
61
|
+
option.getAttribute('data-value') ?? option.getAttribute('value') ?? getLabel(option, index));
|
|
62
|
+
const values = optionElements.map((option, index) => String(getValue(option, index)));
|
|
63
|
+
if (new Set(values).size !== values.length) {
|
|
64
|
+
throw new TypeError('Select menu option values must be unique.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let controller;
|
|
68
|
+
let activeIndex = -1;
|
|
69
|
+
let selectedIndex = options.value == null
|
|
70
|
+
? optionElements.findIndex((option) => option.getAttribute('aria-selected') === 'true')
|
|
71
|
+
: values.indexOf(String(options.value));
|
|
72
|
+
if (options.value != null && selectedIndex < 0) {
|
|
73
|
+
throw new RangeError(`Unknown select menu value: ${options.value}.`);
|
|
74
|
+
}
|
|
75
|
+
if (selectedIndex >= 0 && isDisabled(optionElements[selectedIndex])) selectedIndex = -1;
|
|
76
|
+
|
|
77
|
+
attributes.set(trigger, 'role', 'combobox');
|
|
78
|
+
if (trigger.tagName === 'BUTTON' && !trigger.hasAttribute('type')) {
|
|
79
|
+
attributes.set(trigger, 'type', 'button');
|
|
80
|
+
}
|
|
81
|
+
attributes.set(trigger, 'aria-autocomplete', 'none');
|
|
82
|
+
attributes.set(listbox, 'role', 'listbox');
|
|
83
|
+
optionElements.forEach((option) => {
|
|
84
|
+
attributes.set(option, 'role', 'option');
|
|
85
|
+
attributes.set(option, 'tabindex', '-1');
|
|
86
|
+
ensureId(option, 'ct-select-option', attributes);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const valueElement = options.valueElement == null
|
|
90
|
+
? null
|
|
91
|
+
: assertElement(options.valueElement, 'options.valueElement');
|
|
92
|
+
if (valueElement) assertSameDocument([valueElement], trigger.ownerDocument, 'options.valueElement');
|
|
93
|
+
const originalValueText = valueElement?.textContent;
|
|
94
|
+
|
|
95
|
+
function syncSelection(checkpoint = lifecycle.assertAlive) {
|
|
96
|
+
optionElements.forEach((option, index) => {
|
|
97
|
+
attributes.set(option, 'aria-selected', String(index === selectedIndex));
|
|
98
|
+
});
|
|
99
|
+
if (valueElement && selectedIndex >= 0) {
|
|
100
|
+
const label = getLabel(optionElements[selectedIndex], selectedIndex);
|
|
101
|
+
checkpoint();
|
|
102
|
+
valueElement.textContent = label;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setActive(index) {
|
|
107
|
+
if (index < 0 || index >= optionElements.length || isDisabled(optionElements[index])) return false;
|
|
108
|
+
activeIndex = index;
|
|
109
|
+
optionElements.forEach((option, optionIndex) => {
|
|
110
|
+
attributes.set(option, 'data-highlighted', optionIndex === index ? '' : null);
|
|
111
|
+
});
|
|
112
|
+
attributes.set(trigger, 'aria-activedescendant', optionElements[index].id);
|
|
113
|
+
optionElements[index].scrollIntoView?.({ block: 'nearest' });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function clearActive() {
|
|
118
|
+
activeIndex = -1;
|
|
119
|
+
attributes.set(trigger, 'aria-activedescendant', null);
|
|
120
|
+
optionElements.forEach((option) => attributes.set(option, 'data-highlighted', null));
|
|
121
|
+
typeahead.reset();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const layer = createPopupLayer({
|
|
125
|
+
attributes,
|
|
126
|
+
closeOnEscape: options.closeOnEscape !== false,
|
|
127
|
+
closeOnFocusOutside: true,
|
|
128
|
+
closeOnOutside: options.closeOnOutside !== false,
|
|
129
|
+
disposables,
|
|
130
|
+
getController: () => controller,
|
|
131
|
+
hasPopup: 'listbox',
|
|
132
|
+
handlesTab: true,
|
|
133
|
+
idPrefix: 'ct-select-listbox',
|
|
134
|
+
initialOpen: !isDisabled(trigger) && (options.open ?? root.getAttribute('data-state') === 'open'),
|
|
135
|
+
popup: listbox,
|
|
136
|
+
stateElement: root,
|
|
137
|
+
trigger,
|
|
138
|
+
onOpen(detail, checkpoint) {
|
|
139
|
+
const initial = selectedIndex >= 0 ? selectedIndex : boundaryEnabledIndex(optionElements);
|
|
140
|
+
if (initial >= 0) setActive(initial);
|
|
141
|
+
focusElement(trigger);
|
|
142
|
+
checkpoint();
|
|
143
|
+
options.onOpen?.(detail);
|
|
144
|
+
},
|
|
145
|
+
onClose(detail) {
|
|
146
|
+
clearActive();
|
|
147
|
+
options.onClose?.(detail);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function open(reason = 'programmatic') {
|
|
152
|
+
return lifecycle.run('open', (checkpoint) => {
|
|
153
|
+
if (isDisabled(trigger)) return false;
|
|
154
|
+
const changed = layer.open(reason);
|
|
155
|
+
checkpoint();
|
|
156
|
+
return changed;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function select(target, reason = 'programmatic') {
|
|
161
|
+
return lifecycle.run('select', (checkpoint) => {
|
|
162
|
+
const index = typeof target === 'number' ? target : optionElements.indexOf(target);
|
|
163
|
+
if (!Number.isInteger(index) || index < 0 || index >= optionElements.length) {
|
|
164
|
+
throw new RangeError('Select option is not managed by this controller.');
|
|
165
|
+
}
|
|
166
|
+
if (isDisabled(optionElements[index])) return false;
|
|
167
|
+
if (index === selectedIndex) {
|
|
168
|
+
if (layer.isOpen) layer.close('select');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const detail = {
|
|
173
|
+
controller,
|
|
174
|
+
index,
|
|
175
|
+
option: optionElements[index],
|
|
176
|
+
previousValue: selectedIndex >= 0 ? values[selectedIndex] : null,
|
|
177
|
+
reason,
|
|
178
|
+
value: values[index],
|
|
179
|
+
};
|
|
180
|
+
const allowed = dispatch(listbox, 'ct:beforevaluechange', detail, true);
|
|
181
|
+
checkpoint();
|
|
182
|
+
if (!allowed) return false;
|
|
183
|
+
|
|
184
|
+
selectedIndex = index;
|
|
185
|
+
syncSelection(checkpoint);
|
|
186
|
+
options.onValueChange?.(detail);
|
|
187
|
+
checkpoint();
|
|
188
|
+
dispatch(listbox, 'ct:valuechange', detail);
|
|
189
|
+
checkpoint();
|
|
190
|
+
if (layer.isOpen) layer.close('select');
|
|
191
|
+
return true;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function setValue(value, reason = 'programmatic') {
|
|
196
|
+
lifecycle.assertAlive();
|
|
197
|
+
if (value == null) {
|
|
198
|
+
throw new TypeError('Select menu value cannot be null; select an explicit option.');
|
|
199
|
+
}
|
|
200
|
+
const index = values.indexOf(String(value));
|
|
201
|
+
if (index < 0) throw new RangeError(`Unknown select menu value: ${value}.`);
|
|
202
|
+
return select(index, reason);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onTriggerKeyDown(event) {
|
|
206
|
+
if (isDisabled(trigger)) {
|
|
207
|
+
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', 'Enter', ' '].includes(event.key)) {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (event.key === 'Tab' && layer.isOpen) {
|
|
214
|
+
/* APG select-only combobox: Tab commits the visually focused option
|
|
215
|
+
before the browser moves focus on — otherwise keyboard users
|
|
216
|
+
silently keep the old value where mouse users' click commits. */
|
|
217
|
+
if (activeIndex >= 0 && activeIndex !== selectedIndex) select(activeIndex, 'keyboard');
|
|
218
|
+
if (layer.isOpen) close('tab', { restoreFocus: false });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
if (!layer.isOpen) open('keyboard');
|
|
225
|
+
else if (activeIndex >= 0) select(activeIndex, 'keyboard');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
if (!layer.isOpen) {
|
|
232
|
+
open('keyboard');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const start = activeIndex >= 0 ? activeIndex : selectedIndex;
|
|
236
|
+
setActive(nextEnabledIndex(optionElements, start, event.key === 'ArrowDown' ? 1 : -1, true));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
if (!layer.isOpen) open('keyboard');
|
|
243
|
+
setActive(boundaryEnabledIndex(optionElements, event.key === 'End'));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (event.key === 'PageUp' || event.key === 'PageDown') {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
if (!layer.isOpen) open('keyboard');
|
|
250
|
+
const direction = event.key === 'PageDown' ? 1 : -1;
|
|
251
|
+
let index = activeIndex >= 0 ? activeIndex : selectedIndex;
|
|
252
|
+
for (let step = 0; step < 10; step += 1) {
|
|
253
|
+
const next = nextEnabledIndex(optionElements, index, direction, false);
|
|
254
|
+
if (next < 0) break;
|
|
255
|
+
index = next;
|
|
256
|
+
}
|
|
257
|
+
if (index >= 0) setActive(index);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const match = typeahead.find(event, optionElements, activeIndex >= 0 ? activeIndex : selectedIndex);
|
|
262
|
+
if (match >= 0) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
if (layer.isOpen) setActive(match);
|
|
265
|
+
else select(match, 'typeahead');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function close(reason = 'programmatic', closeOptions) {
|
|
270
|
+
return lifecycle.run('close', (checkpoint) => {
|
|
271
|
+
const changed = layer.close(reason, closeOptions);
|
|
272
|
+
checkpoint();
|
|
273
|
+
return changed;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function toggle(reason = 'programmatic') {
|
|
278
|
+
return lifecycle.run('toggle', (checkpoint) => {
|
|
279
|
+
if (isDisabled(trigger)) return false;
|
|
280
|
+
const changed = layer.toggle(reason);
|
|
281
|
+
checkpoint();
|
|
282
|
+
return changed;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
disposables.listen(trigger, 'click', (event) => {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
if (!isDisabled(trigger)) toggle('trigger');
|
|
289
|
+
});
|
|
290
|
+
disposables.listen(trigger, 'keydown', onTriggerKeyDown);
|
|
291
|
+
optionElements.forEach((option, index) => {
|
|
292
|
+
disposables.listen(option, 'pointermove', (event) => {
|
|
293
|
+
if (event.pointerType !== 'touch') setActive(index);
|
|
294
|
+
});
|
|
295
|
+
disposables.listen(option, 'click', (event) => {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
if (!isDisabled(option)) select(index, 'pointer');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
controller = Object.freeze({
|
|
302
|
+
get root() {
|
|
303
|
+
return root;
|
|
304
|
+
},
|
|
305
|
+
get trigger() {
|
|
306
|
+
return trigger;
|
|
307
|
+
},
|
|
308
|
+
get listbox() {
|
|
309
|
+
return listbox;
|
|
310
|
+
},
|
|
311
|
+
get options() {
|
|
312
|
+
return [...optionElements];
|
|
313
|
+
},
|
|
314
|
+
get isOpen() {
|
|
315
|
+
return layer.isOpen;
|
|
316
|
+
},
|
|
317
|
+
get value() {
|
|
318
|
+
return selectedIndex >= 0 ? values[selectedIndex] : null;
|
|
319
|
+
},
|
|
320
|
+
open,
|
|
321
|
+
close,
|
|
322
|
+
toggle,
|
|
323
|
+
select,
|
|
324
|
+
setValue,
|
|
325
|
+
destroy() {
|
|
326
|
+
if (!lifecycle.destroy()) return;
|
|
327
|
+
layer.destroy();
|
|
328
|
+
typeahead.reset();
|
|
329
|
+
disposables.dispose();
|
|
330
|
+
attributes.restore();
|
|
331
|
+
if (valueElement) valueElement.textContent = originalValueText;
|
|
332
|
+
controllers.delete(trigger);
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
controllers.set(trigger, controller);
|
|
337
|
+
syncSelection();
|
|
338
|
+
layer.activateInitialOpen();
|
|
339
|
+
if (layer.isOpen) {
|
|
340
|
+
const initial = selectedIndex >= 0 ? selectedIndex : boundaryEnabledIndex(optionElements);
|
|
341
|
+
if (initial >= 0) setActive(initial);
|
|
342
|
+
}
|
|
343
|
+
return controller;
|
|
344
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertChoice,
|
|
3
|
+
assertDescendants,
|
|
4
|
+
assertElement,
|
|
5
|
+
assertElements,
|
|
6
|
+
assertFunction,
|
|
7
|
+
assertSameDocument,
|
|
8
|
+
assertUniqueElements,
|
|
9
|
+
boundaryEnabledIndex,
|
|
10
|
+
createAttributeStore,
|
|
11
|
+
createControllerLifecycle,
|
|
12
|
+
createDisposables,
|
|
13
|
+
dispatch,
|
|
14
|
+
ensureId,
|
|
15
|
+
focusElement,
|
|
16
|
+
focusIntoView,
|
|
17
|
+
isDisabled,
|
|
18
|
+
nextEnabledIndex,
|
|
19
|
+
orientationDelta,
|
|
20
|
+
warnAccessibility,
|
|
21
|
+
} from './internal/dom.js';
|
|
22
|
+
|
|
23
|
+
const controllers = new WeakMap();
|
|
24
|
+
|
|
25
|
+
function resolvePanels(tablist, tabs, suppliedPanels) {
|
|
26
|
+
if (suppliedPanels != null) return assertElements(suppliedPanels, 'options.panels');
|
|
27
|
+
|
|
28
|
+
const document = tablist.ownerDocument;
|
|
29
|
+
const controlledPanels = tabs.map((tab) => {
|
|
30
|
+
const id = tab.getAttribute('aria-controls');
|
|
31
|
+
return id ? document.getElementById(id) : null;
|
|
32
|
+
});
|
|
33
|
+
if (controlledPanels.every(Boolean)) return controlledPanels;
|
|
34
|
+
|
|
35
|
+
const scope = tablist.parentElement ?? tablist;
|
|
36
|
+
const positional = [...scope.querySelectorAll('[role="tabpanel"], [data-ct-tab-panel]')];
|
|
37
|
+
if (positional.length > 1) {
|
|
38
|
+
warnAccessibility(
|
|
39
|
+
'Tabs: panels were paired with tabs by DOM position. If panel order differs from ' +
|
|
40
|
+
'tab order, tabs announce the wrong panels — set aria-controls on each tab or ' +
|
|
41
|
+
'pass options.panels explicitly.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return positional;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates an ARIA tabs controller with roving focus and automatic or manual activation.
|
|
49
|
+
*/
|
|
50
|
+
export function createTabsController(options) {
|
|
51
|
+
if (!options || typeof options !== 'object') {
|
|
52
|
+
throw new TypeError('Tabs controller options are required.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tablist = assertElement(options.tablist, 'options.tablist');
|
|
56
|
+
const existing = controllers.get(tablist);
|
|
57
|
+
if (existing) return existing;
|
|
58
|
+
|
|
59
|
+
const tabs = options.tabs == null
|
|
60
|
+
? [...tablist.querySelectorAll('[role="tab"], [data-ct-tab]')]
|
|
61
|
+
: assertElements(options.tabs, 'options.tabs');
|
|
62
|
+
const panels = resolvePanels(tablist, tabs, options.panels);
|
|
63
|
+
if (!tabs.length) throw new TypeError('Tabs controller requires at least one tab.');
|
|
64
|
+
assertUniqueElements(tabs, 'options.tabs');
|
|
65
|
+
assertDescendants(tabs, tablist, 'options.tabs');
|
|
66
|
+
assertUniqueElements(panels, 'options.panels');
|
|
67
|
+
assertSameDocument(panels, tablist.ownerDocument, 'options.panels');
|
|
68
|
+
if (tabs.length !== panels.length || panels.some((panel) => !panel)) {
|
|
69
|
+
throw new TypeError('Each tab must map to exactly one panel.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const orientation = assertChoice(
|
|
73
|
+
options.orientation ?? tablist.getAttribute('aria-orientation') ?? 'horizontal',
|
|
74
|
+
['horizontal', 'vertical'],
|
|
75
|
+
'options.orientation',
|
|
76
|
+
);
|
|
77
|
+
const activation = assertChoice(
|
|
78
|
+
options.activation ?? 'automatic',
|
|
79
|
+
['automatic', 'manual'],
|
|
80
|
+
'options.activation',
|
|
81
|
+
);
|
|
82
|
+
assertFunction(options.onSelect, 'options.onSelect');
|
|
83
|
+
|
|
84
|
+
const attributes = createAttributeStore();
|
|
85
|
+
const lifecycle = createControllerLifecycle('Tabs');
|
|
86
|
+
const disposables = createDisposables();
|
|
87
|
+
const loop = options.loop !== false;
|
|
88
|
+
|
|
89
|
+
const selectedFromMarkup = tabs.findIndex(
|
|
90
|
+
(tab) => tab.getAttribute('aria-selected') === 'true' && !isDisabled(tab),
|
|
91
|
+
);
|
|
92
|
+
let selectedIndex = selectedFromMarkup;
|
|
93
|
+
if (options.selectedIndex != null) {
|
|
94
|
+
if (
|
|
95
|
+
!Number.isInteger(options.selectedIndex) ||
|
|
96
|
+
options.selectedIndex < 0 ||
|
|
97
|
+
options.selectedIndex >= tabs.length ||
|
|
98
|
+
isDisabled(tabs[options.selectedIndex])
|
|
99
|
+
) {
|
|
100
|
+
throw new RangeError('options.selectedIndex must identify an enabled tab.');
|
|
101
|
+
}
|
|
102
|
+
selectedIndex = options.selectedIndex;
|
|
103
|
+
} else if (selectedIndex < 0) {
|
|
104
|
+
selectedIndex = boundaryEnabledIndex(tabs);
|
|
105
|
+
}
|
|
106
|
+
if (selectedIndex < 0) throw new TypeError('Tabs controller requires at least one enabled tab.');
|
|
107
|
+
let focusIndex = selectedIndex;
|
|
108
|
+
let movingFocus = false;
|
|
109
|
+
|
|
110
|
+
attributes.set(tablist, 'role', 'tablist');
|
|
111
|
+
attributes.set(tablist, 'aria-orientation', orientation);
|
|
112
|
+
|
|
113
|
+
tabs.forEach((tab, index) => {
|
|
114
|
+
const panel = panels[index];
|
|
115
|
+
const tabId = ensureId(tab, 'ct-tab', attributes);
|
|
116
|
+
const panelId = ensureId(panel, 'ct-tab-panel', attributes);
|
|
117
|
+
attributes.set(tab, 'role', 'tab');
|
|
118
|
+
attributes.set(tab, 'aria-controls', panelId);
|
|
119
|
+
attributes.set(panel, 'role', 'tabpanel');
|
|
120
|
+
attributes.set(panel, 'aria-labelledby', tabId);
|
|
121
|
+
if (!panel.hasAttribute('tabindex')) attributes.set(panel, 'tabindex', '0');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function sync() {
|
|
125
|
+
tabs.forEach((tab, index) => {
|
|
126
|
+
const selected = index === selectedIndex;
|
|
127
|
+
attributes.set(tab, 'aria-selected', String(selected));
|
|
128
|
+
attributes.set(tab, 'data-state', selected ? 'active' : 'inactive');
|
|
129
|
+
attributes.set(tab, 'tabindex', index === focusIndex && !isDisabled(tab) ? '0' : '-1');
|
|
130
|
+
attributes.set(panels[index], 'data-state', selected ? 'active' : 'inactive');
|
|
131
|
+
attributes.set(panels[index], 'hidden', selected ? null : '');
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function select(target, { focus = false, reason = 'programmatic' } = {}) {
|
|
136
|
+
return lifecycle.run('select', (checkpoint) => {
|
|
137
|
+
const index = typeof target === 'number' ? target : tabs.indexOf(target);
|
|
138
|
+
if (!Number.isInteger(index) || index < 0 || index >= tabs.length) {
|
|
139
|
+
throw new RangeError('Tab target is not managed by this controller.');
|
|
140
|
+
}
|
|
141
|
+
if (isDisabled(tabs[index])) return false;
|
|
142
|
+
|
|
143
|
+
focusIndex = index;
|
|
144
|
+
if (index === selectedIndex) {
|
|
145
|
+
sync();
|
|
146
|
+
if (focus) focusElement(tabs[index]);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const detail = { controller, index, panel: panels[index], reason, tab: tabs[index] };
|
|
151
|
+
const allowed = dispatch(tablist, 'ct:beforeselect', detail, true);
|
|
152
|
+
checkpoint();
|
|
153
|
+
if (!allowed) return false;
|
|
154
|
+
|
|
155
|
+
selectedIndex = index;
|
|
156
|
+
sync();
|
|
157
|
+
if (focus) {
|
|
158
|
+
focusElement(tabs[index]);
|
|
159
|
+
checkpoint();
|
|
160
|
+
}
|
|
161
|
+
options.onSelect?.(detail);
|
|
162
|
+
checkpoint();
|
|
163
|
+
dispatch(tablist, 'ct:select', detail);
|
|
164
|
+
checkpoint();
|
|
165
|
+
return true;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function moveFocus(index, reason) {
|
|
170
|
+
focusIndex = index;
|
|
171
|
+
sync();
|
|
172
|
+
movingFocus = true;
|
|
173
|
+
try {
|
|
174
|
+
/* Overflowing tablists scroll; keep the roving focus visible. */
|
|
175
|
+
focusIntoView(tabs[index]);
|
|
176
|
+
} finally {
|
|
177
|
+
movingFocus = false;
|
|
178
|
+
}
|
|
179
|
+
if (activation === 'automatic') select(index, { reason });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onKeyDown(event, index) {
|
|
183
|
+
const delta = orientationDelta(event.key, orientation, tablist);
|
|
184
|
+
if (delta) {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
moveFocus(nextEnabledIndex(tabs, index, delta, loop), 'keyboard');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
moveFocus(boundaryEnabledIndex(tabs, event.key === 'End'), 'keyboard');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (activation === 'manual' && (event.key === 'Enter' || event.key === ' ')) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
select(index, { focus: true, reason: 'keyboard' });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
tabs.forEach((tab, index) => {
|
|
203
|
+
disposables.listen(tab, 'click', (event) => {
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
if (!isDisabled(tab)) select(index, { reason: 'pointer' });
|
|
206
|
+
});
|
|
207
|
+
disposables.listen(tab, 'focus', () => {
|
|
208
|
+
if (isDisabled(tab)) return;
|
|
209
|
+
focusIndex = index;
|
|
210
|
+
sync();
|
|
211
|
+
if (activation === 'automatic' && !movingFocus) select(index, { reason: 'focus' });
|
|
212
|
+
});
|
|
213
|
+
disposables.listen(tab, 'keydown', (event) => onKeyDown(event, index));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const controller = Object.freeze({
|
|
217
|
+
get tablist() {
|
|
218
|
+
return tablist;
|
|
219
|
+
},
|
|
220
|
+
get tabs() {
|
|
221
|
+
return [...tabs];
|
|
222
|
+
},
|
|
223
|
+
get panels() {
|
|
224
|
+
return [...panels];
|
|
225
|
+
},
|
|
226
|
+
get selectedIndex() {
|
|
227
|
+
return selectedIndex;
|
|
228
|
+
},
|
|
229
|
+
select,
|
|
230
|
+
destroy() {
|
|
231
|
+
if (!lifecycle.destroy()) return;
|
|
232
|
+
disposables.dispose();
|
|
233
|
+
attributes.restore();
|
|
234
|
+
controllers.delete(tablist);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
controllers.set(tablist, controller);
|
|
239
|
+
sync();
|
|
240
|
+
return controller;
|
|
241
|
+
}
|