@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.
Files changed (138) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/README.md +158 -149
  3. package/behaviors/combobox.js +486 -0
  4. package/behaviors/drawer.js +18 -0
  5. package/behaviors/dropdown.js +310 -0
  6. package/behaviors/index.d.ts +335 -0
  7. package/behaviors/index.js +9 -0
  8. package/behaviors/internal/dialog-controller.js +421 -0
  9. package/behaviors/internal/dom.js +408 -0
  10. package/behaviors/internal/overlay.js +241 -0
  11. package/behaviors/internal/popup-layer.js +190 -0
  12. package/behaviors/modal.js +17 -0
  13. package/behaviors/popover.js +278 -0
  14. package/behaviors/select-menu.js +344 -0
  15. package/behaviors/tabs.js +241 -0
  16. package/behaviors/toggle-group.js +266 -0
  17. package/behaviors/tooltip.js +257 -0
  18. package/components/README.md +319 -83
  19. package/components/_shared.css +22 -10
  20. package/components/_shell-shared.css +20 -4
  21. package/components/accordion.css +8 -0
  22. package/components/app-shell-v2.css +28 -14
  23. package/components/app-shell.css +30 -14
  24. package/components/badge.css +15 -0
  25. package/components/banner.css +32 -1
  26. package/components/button.css +103 -15
  27. package/components/card.css +67 -8
  28. package/components/chart.css +26 -0
  29. package/components/checkbox.css +35 -2
  30. package/components/chip.css +30 -2
  31. package/components/combobox.css +21 -0
  32. package/components/datepicker.css +58 -9
  33. package/components/datum.css +277 -0
  34. package/components/dropdown.css +23 -3
  35. package/components/field.css +6 -4
  36. package/components/file-upload.css +1 -1
  37. package/components/index.css +5 -0
  38. package/components/list.css +28 -2
  39. package/components/meter.css +182 -0
  40. package/components/navbar.css +102 -4
  41. package/components/pagination.css +18 -0
  42. package/components/progress-bar.css +22 -1
  43. package/components/select-menu.css +8 -0
  44. package/components/select.css +8 -0
  45. package/components/sidebar.css +15 -2
  46. package/components/skip-link.css +6 -2
  47. package/components/slider.css +7 -1
  48. package/components/status.css +107 -0
  49. package/components/switch.css +66 -5
  50. package/components/table.css +53 -4
  51. package/components/tabs.css +55 -3
  52. package/components/toast.css +10 -1
  53. package/components/toggle-group.css +16 -5
  54. package/components/toolbar.css +17 -1
  55. package/components/tooltip.css +4 -0
  56. package/components/tree.css +30 -0
  57. package/docs/architecture.md +79 -0
  58. package/docs/behaviors.md +122 -0
  59. package/docs/fonts.md +70 -0
  60. package/docs/governance.md +85 -0
  61. package/docs/guidelines.md +72 -25
  62. package/examples/react/README.md +20 -0
  63. package/examples/react/SettingsModal.jsx +66 -0
  64. package/examples/react/construct.css +5 -0
  65. package/fonts.css +10 -0
  66. package/foundations.css +14 -1
  67. package/package.json +76 -21
  68. package/schemas/components.schema.json +122 -0
  69. package/schemas/examples/theme.example.json +27 -0
  70. package/schemas/primitives.schema.json +135 -0
  71. package/schemas/semantic.schema.json +224 -0
  72. package/schemas/theme.schema.json +63 -0
  73. package/scripts/build-tokens.mjs +1499 -0
  74. package/scripts/construct-theme.mjs +43 -0
  75. package/scripts/validate-tokens.mjs +25 -0
  76. package/standalone/accordion.css +3 -0
  77. package/standalone/alert.css +3 -0
  78. package/standalone/app-shell-v2.css +4 -0
  79. package/standalone/app-shell.css +4 -0
  80. package/standalone/avatar.css +3 -0
  81. package/standalone/badge.css +3 -0
  82. package/standalone/banner.css +3 -0
  83. package/standalone/breadcrumbs.css +3 -0
  84. package/standalone/button.css +3 -0
  85. package/standalone/card.css +3 -0
  86. package/standalone/chart.css +3 -0
  87. package/standalone/checkbox.css +3 -0
  88. package/standalone/chip.css +3 -0
  89. package/standalone/combobox.css +3 -0
  90. package/standalone/core.css +4 -0
  91. package/standalone/data-table.css +3 -0
  92. package/standalone/datepicker.css +3 -0
  93. package/standalone/datum.css +3 -0
  94. package/standalone/divider.css +3 -0
  95. package/standalone/drawer.css +3 -0
  96. package/standalone/dropdown.css +3 -0
  97. package/standalone/empty-state.css +3 -0
  98. package/standalone/field.css +3 -0
  99. package/standalone/file-upload.css +3 -0
  100. package/standalone/icon.css +3 -0
  101. package/standalone/input.css +3 -0
  102. package/standalone/list.css +3 -0
  103. package/standalone/meter.css +3 -0
  104. package/standalone/modal.css +3 -0
  105. package/standalone/navbar.css +3 -0
  106. package/standalone/pagination.css +3 -0
  107. package/standalone/popover.css +3 -0
  108. package/standalone/progress-bar.css +3 -0
  109. package/standalone/select-menu.css +3 -0
  110. package/standalone/select.css +3 -0
  111. package/standalone/sidebar.css +3 -0
  112. package/standalone/skeleton.css +3 -0
  113. package/standalone/skip-link.css +3 -0
  114. package/standalone/slider.css +3 -0
  115. package/standalone/spinner.css +3 -0
  116. package/standalone/status.css +3 -0
  117. package/standalone/switch.css +3 -0
  118. package/standalone/table.css +3 -0
  119. package/standalone/tabs.css +3 -0
  120. package/standalone/textarea.css +3 -0
  121. package/standalone/toast.css +3 -0
  122. package/standalone/toggle-group.css +3 -0
  123. package/standalone/toolbar.css +3 -0
  124. package/standalone/tooltip.css +3 -0
  125. package/standalone/tree.css +3 -0
  126. package/themes/README.md +37 -0
  127. package/themes/walnut.theme.json +106 -0
  128. package/tokens/README.md +135 -123
  129. package/tokens/components.json +64 -0
  130. package/tokens/primitives.json +268 -0
  131. package/tokens/semantic.dark.json +94 -0
  132. package/tokens/semantic.high-contrast-dark.json +94 -0
  133. package/tokens/semantic.high-contrast.json +94 -0
  134. package/tokens/semantic.light.json +179 -0
  135. package/tokens/tokens.css +960 -58
  136. package/tokens/tokens.js +729 -120
  137. package/tokens/tokens.json +1158 -116
  138. package/tokens/tokens.ts +729 -120
@@ -0,0 +1,421 @@
1
+ import {
2
+ assertChoice,
3
+ assertElement,
4
+ assertElements,
5
+ assertFunction,
6
+ assertSameDocument,
7
+ createAttributeStore,
8
+ createControllerLifecycle,
9
+ createDisposables,
10
+ dispatch,
11
+ ensureId,
12
+ focusElement,
13
+ focusIntoView,
14
+ getFocusableElements,
15
+ isComposingEvent,
16
+ isElement,
17
+ isDisabled,
18
+ warnAccessibility,
19
+ } from './dom.js';
20
+ import {
21
+ closeOwnedOverlays,
22
+ consumeTopOverlayEvent,
23
+ destroyOwnedOverlays,
24
+ getModalFocusScopes,
25
+ isInModalFocusScope,
26
+ isTopModalOverlay,
27
+ lockBodyScroll,
28
+ pushOverlay,
29
+ topOverlayHandlesModalTab,
30
+ trapTabKey,
31
+ } from './overlay.js';
32
+
33
+ function resolveInitialFocus(initialFocus, dialog) {
34
+ let target = initialFocus;
35
+ if (typeof target === 'function') target = target();
36
+ if (typeof target === 'string') {
37
+ target = dialog.querySelector(target);
38
+ if (!target) throw new TypeError('initialFocus selector did not match an Element inside dialog.');
39
+ }
40
+ if (target == null) target = dialog.querySelector('[autofocus]');
41
+ if (target == null) target = getFocusableElements(dialog)[0];
42
+
43
+ if (target != null && (!isElement(target) || !dialog.contains(target))) {
44
+ throw new TypeError('initialFocus must resolve to an Element inside dialog.');
45
+ }
46
+
47
+ return target;
48
+ }
49
+
50
+ function validateInitialFocus(initialFocus, dialog) {
51
+ if (initialFocus == null || typeof initialFocus === 'function') return;
52
+ if (typeof initialFocus === 'string') {
53
+ if (!dialog.querySelector(initialFocus)) {
54
+ throw new TypeError('initialFocus selector did not match an Element inside dialog.');
55
+ }
56
+ return;
57
+ }
58
+ if (!isElement(initialFocus) || !dialog.contains(initialFocus)) {
59
+ throw new TypeError('initialFocus must be an Element inside dialog, a selector, or a function.');
60
+ }
61
+ }
62
+
63
+ export function createDialogController(options, registry, defaults) {
64
+ if (!options || typeof options !== 'object') {
65
+ throw new TypeError('Dialog controller options are required.');
66
+ }
67
+
68
+ const container = assertElement(options.container, 'options.container');
69
+ const existing = registry.get(container);
70
+ if (existing) return existing;
71
+
72
+ const dialog = assertElement(options.dialog ?? container, 'options.dialog');
73
+ if (dialog !== container && !container.contains(dialog)) {
74
+ throw new TypeError('options.dialog must be options.container or a descendant of it.');
75
+ }
76
+
77
+ const trigger = options.trigger == null ? null : assertElement(options.trigger, 'options.trigger');
78
+ const backdrop = options.backdrop === false
79
+ ? null
80
+ : assertElement(options.backdrop ?? container, 'options.backdrop');
81
+ const closeButtons = options.closeButtons == null
82
+ ? [...dialog.querySelectorAll('[data-ct-dismiss]')]
83
+ : assertElements(options.closeButtons, 'options.closeButtons');
84
+ assertSameDocument(closeButtons, container.ownerDocument, 'options.closeButtons');
85
+ if (trigger) assertSameDocument([trigger], container.ownerDocument, 'options.trigger');
86
+ if (backdrop) assertSameDocument([backdrop], container.ownerDocument, 'options.backdrop');
87
+ const triggerAction = assertChoice(
88
+ options.triggerAction ?? defaults.triggerAction,
89
+ ['open', 'toggle'],
90
+ 'options.triggerAction',
91
+ );
92
+
93
+ assertFunction(options.onOpen, 'options.onOpen');
94
+ assertFunction(options.onClose, 'options.onClose');
95
+ validateInitialFocus(options.initialFocus, dialog);
96
+
97
+ const attributes = createAttributeStore();
98
+ const lifecycle = createControllerLifecycle(defaults.name);
99
+ const disposables = createDisposables();
100
+ const document = container.ownerDocument;
101
+ const token = {};
102
+ const closeOnEscape = options.closeOnEscape !== false;
103
+ const closeOnBackdrop = options.closeOnBackdrop !== false;
104
+ const trapFocus = options.trapFocus !== false;
105
+ const returnFocus = options.returnFocus !== false;
106
+ const lockScroll = options.lockScroll !== false;
107
+ const inertBackground = options.inertBackground === true;
108
+ let open = options.open ?? container.getAttribute('data-state') === 'open';
109
+ let previouslyFocused = null;
110
+ let releaseBodyLock = null;
111
+ let removeFromStack = null;
112
+ let inertedElements = null;
113
+ let warnedMissingName = false;
114
+
115
+ const dialogId = ensureId(dialog, `${defaults.idPrefix}-dialog`, attributes);
116
+ attributes.set(dialog, 'role', dialog.getAttribute('role') ?? 'dialog');
117
+ attributes.set(dialog, 'aria-modal', 'true');
118
+ if (trigger) {
119
+ attributes.set(trigger, 'aria-haspopup', 'dialog');
120
+ attributes.set(trigger, 'aria-controls', dialogId);
121
+ }
122
+
123
+ function syncState() {
124
+ attributes.set(container, 'data-state', open ? 'open' : 'closed');
125
+ attributes.set(container, 'hidden', open ? null : '');
126
+ if (trigger) attributes.set(trigger, 'aria-expanded', String(open));
127
+ }
128
+
129
+ function releaseOpenResources() {
130
+ removeFromStack?.();
131
+ removeFromStack = null;
132
+ releaseBodyLock?.();
133
+ releaseBodyLock = null;
134
+ releaseBackgroundInert();
135
+ }
136
+
137
+ /* aria-modal hides background content from modern AT, but legacy virtual
138
+ cursors can still wander behind the dialog. Opt-in `inertBackground`
139
+ additionally inerts the container's DOM siblings up to <body>. Nodes
140
+ appended after opening (e.g. portaled popups) are unaffected. */
141
+ function applyBackgroundInert() {
142
+ if (!inertBackground || inertedElements) return;
143
+ inertedElements = [];
144
+ for (let node = container; node.parentElement && node !== document.body; node = node.parentElement) {
145
+ for (const sibling of node.parentElement.children) {
146
+ if (sibling === node || sibling.inert) continue;
147
+ const tag = sibling.tagName;
148
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'TEMPLATE') continue;
149
+ sibling.inert = true;
150
+ inertedElements.push(sibling);
151
+ }
152
+ }
153
+ }
154
+
155
+ function releaseBackgroundInert() {
156
+ if (!inertedElements) return;
157
+ for (const element of inertedElements) element.inert = false;
158
+ inertedElements = null;
159
+ }
160
+
161
+ function warnWhenUnnamed() {
162
+ if (warnedMissingName) return;
163
+ warnedMissingName = true;
164
+ if (!dialog.hasAttribute('aria-label') && !dialog.hasAttribute('aria-labelledby')) {
165
+ warnAccessibility(
166
+ `${defaults.name}: the dialog element has neither aria-labelledby nor aria-label. ` +
167
+ 'Dialogs must expose an accessible name (WCAG 4.1.2).',
168
+ );
169
+ }
170
+ }
171
+
172
+ function registerDialogOverlay() {
173
+ return pushOverlay(document, token, {
174
+ modal: true,
175
+ onOwnerClose: () => closeDialog('owner-close', { restoreFocus: false }),
176
+ onOwnerDestroy: () => controller.destroy(),
177
+ scope: dialog,
178
+ trigger,
179
+ });
180
+ }
181
+
182
+ function focusInside(checkpoint = lifecycle.assertAlive) {
183
+ const target = resolveInitialFocus(options.initialFocus, dialog);
184
+ checkpoint();
185
+ if (target && focusIntoView(target)) {
186
+ checkpoint();
187
+ return;
188
+ }
189
+
190
+ if (!dialog.hasAttribute('tabindex')) attributes.set(dialog, 'tabindex', '-1');
191
+ focusElement(dialog);
192
+ checkpoint();
193
+ }
194
+
195
+ function restoreDialogFocus(target) {
196
+ if (focusIntoView(target)) return true;
197
+ if (target !== trigger && focusIntoView(trigger)) return true;
198
+
199
+ const active = document.activeElement;
200
+ if (isElement(active) && dialog.contains(active) && typeof active.blur === 'function') {
201
+ active.blur();
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function openDialog(reason = 'programmatic') {
207
+ return lifecycle.run('open', (checkpoint) => {
208
+ if (open) return false;
209
+
210
+ const detail = { controller, reason };
211
+ const allowed = dispatch(container, 'ct:beforeopen', detail, true);
212
+ checkpoint();
213
+ if (!allowed) return false;
214
+
215
+ previouslyFocused = isElement(document.activeElement) ? document.activeElement : null;
216
+ open = true;
217
+ syncState();
218
+ warnWhenUnnamed();
219
+ removeFromStack = registerDialogOverlay();
220
+ if (lockScroll) releaseBodyLock = lockBodyScroll(document);
221
+ applyBackgroundInert();
222
+ try {
223
+ focusInside(checkpoint);
224
+ options.onOpen?.(detail);
225
+ checkpoint();
226
+ } catch (error) {
227
+ if (lifecycle.isAlive) {
228
+ open = false;
229
+ syncState();
230
+ releaseOpenResources();
231
+ const focusTarget = previouslyFocused?.isConnected ? previouslyFocused : trigger;
232
+ restoreDialogFocus(focusTarget);
233
+ previouslyFocused = null;
234
+ }
235
+ throw error;
236
+ }
237
+ dispatch(container, 'ct:open', detail);
238
+ checkpoint();
239
+ return true;
240
+ });
241
+ }
242
+
243
+ function closeDialog(reason = 'programmatic', { restoreFocus = returnFocus } = {}) {
244
+ return lifecycle.run('close', (checkpoint) => {
245
+ if (!open) return false;
246
+
247
+ const detail = { controller, reason };
248
+ const allowed = dispatch(container, 'ct:beforeclose', detail, true);
249
+ checkpoint();
250
+ if (!allowed) return false;
251
+ if (!closeOwnedOverlays(document, token)) return false;
252
+ checkpoint();
253
+
254
+ open = false;
255
+ syncState();
256
+ releaseOpenResources();
257
+
258
+ const focusTarget = previouslyFocused?.isConnected ? previouslyFocused : trigger;
259
+ previouslyFocused = null;
260
+ if (restoreFocus) {
261
+ restoreDialogFocus(focusTarget);
262
+ checkpoint();
263
+ }
264
+ options.onClose?.(detail);
265
+ checkpoint();
266
+ dispatch(container, 'ct:close', detail);
267
+ checkpoint();
268
+ return true;
269
+ });
270
+ }
271
+
272
+ function toggleDialog(reason = 'programmatic') {
273
+ lifecycle.assertAlive();
274
+ return open ? closeDialog(reason) : openDialog(reason);
275
+ }
276
+
277
+ function onDocumentKeyDown(event) {
278
+ if (!open) return;
279
+ /* Never treat keys as dialog shortcuts while an IME composition is
280
+ active — Escape/Tab are cancelling or committing composed text. */
281
+ if (isComposingEvent(event)) return;
282
+
283
+ if (
284
+ event.key === 'Escape' &&
285
+ closeOnEscape &&
286
+ consumeTopOverlayEvent(event, document, token)
287
+ ) {
288
+ event.preventDefault();
289
+ event.stopPropagation();
290
+ closeDialog('escape');
291
+ return;
292
+ }
293
+
294
+ if (
295
+ trapFocus &&
296
+ isTopModalOverlay(document, token) &&
297
+ !topOverlayHandlesModalTab(document, token)
298
+ ) {
299
+ trapTabKey(event, getModalFocusScopes(document, token));
300
+ }
301
+ }
302
+
303
+ function onDocumentFocusIn(event) {
304
+ if (
305
+ !open ||
306
+ !trapFocus ||
307
+ !isTopModalOverlay(document, token) ||
308
+ isInModalFocusScope(document, token, event.target)
309
+ ) {
310
+ return;
311
+ }
312
+
313
+ focusInside();
314
+ }
315
+
316
+ if (trigger) {
317
+ disposables.listen(trigger, 'click', (event) => {
318
+ event.preventDefault();
319
+ if (isDisabled(trigger)) return;
320
+ if (triggerAction === 'toggle') toggleDialog('trigger');
321
+ else openDialog('trigger');
322
+ });
323
+ }
324
+
325
+ closeButtons.forEach((button) => {
326
+ disposables.listen(button, 'click', (event) => {
327
+ event.preventDefault();
328
+ if (!isDisabled(button)) closeDialog('dismiss');
329
+ });
330
+ });
331
+
332
+ if (backdrop) {
333
+ /* Close only when the pointer both went down AND came up on the backdrop
334
+ itself. A plain click listener also fires when a drag (e.g. selecting
335
+ text in a form field) starts inside the dialog and is released over
336
+ the backdrop — the click retargets to their common ancestor, which is
337
+ the backdrop — and would discard the user's in-progress work. Routing
338
+ the pointerdown through the overlay stack also keeps a single gesture
339
+ from dismissing a popup and this dialog together. */
340
+ let pointerDownOnBackdrop = false;
341
+ disposables.listen(backdrop, 'pointerdown', (event) => {
342
+ pointerDownOnBackdrop =
343
+ open &&
344
+ closeOnBackdrop &&
345
+ event.target === backdrop &&
346
+ consumeTopOverlayEvent(event, document, token);
347
+ });
348
+ disposables.listen(backdrop, 'pointercancel', () => {
349
+ pointerDownOnBackdrop = false;
350
+ });
351
+ disposables.listen(backdrop, 'pointerup', (event) => {
352
+ const shouldClose = pointerDownOnBackdrop && event.target === backdrop;
353
+ pointerDownOnBackdrop = false;
354
+ if (shouldClose && open) closeDialog('backdrop');
355
+ });
356
+ }
357
+
358
+ disposables.listen(document, 'keydown', onDocumentKeyDown, true);
359
+ disposables.listen(document, 'focusin', onDocumentFocusIn, true);
360
+
361
+ const controller = Object.freeze({
362
+ get container() {
363
+ return container;
364
+ },
365
+ get dialog() {
366
+ return dialog;
367
+ },
368
+ get trigger() {
369
+ return trigger;
370
+ },
371
+ get isOpen() {
372
+ return open;
373
+ },
374
+ open: openDialog,
375
+ close: closeDialog,
376
+ toggle: toggleDialog,
377
+ destroy() {
378
+ if (!lifecycle.isAlive) return;
379
+ destroyOwnedOverlays(document, token);
380
+ if (!lifecycle.destroy()) return;
381
+ if (open) {
382
+ open = false;
383
+ /* Frameworks often unmount an open dialog without closing it first;
384
+ without a hand-off the browser drops focus to <body> and silently
385
+ teleports keyboard/AT users to the top of the page. */
386
+ const active = document.activeElement;
387
+ if (isElement(active) && dialog.contains(active)) {
388
+ restoreDialogFocus(previouslyFocused?.isConnected ? previouslyFocused : trigger);
389
+ }
390
+ previouslyFocused = null;
391
+ releaseOpenResources();
392
+ }
393
+ disposables.dispose();
394
+ attributes.restore();
395
+ registry.delete(container);
396
+ },
397
+ });
398
+
399
+ registry.set(container, controller);
400
+ syncState();
401
+ if (open) {
402
+ previouslyFocused = isElement(document.activeElement) ? document.activeElement : null;
403
+ warnWhenUnnamed();
404
+ removeFromStack = registerDialogOverlay();
405
+ if (lockScroll) releaseBodyLock = lockBodyScroll(document);
406
+ applyBackgroundInert();
407
+ try {
408
+ focusInside();
409
+ } catch (error) {
410
+ open = false;
411
+ releaseOpenResources();
412
+ lifecycle.destroy();
413
+ disposables.dispose();
414
+ attributes.restore();
415
+ registry.delete(container);
416
+ throw error;
417
+ }
418
+ }
419
+
420
+ return controller;
421
+ }