@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,190 @@
1
+ import {
2
+ createControllerLifecycle,
3
+ dispatch,
4
+ ensureId,
5
+ eventTargetsOutside,
6
+ focusElement,
7
+ isComposingEvent,
8
+ isElement,
9
+ } from './dom.js';
10
+ import { consumeTopOverlayEvent, pushOverlay } from './overlay.js';
11
+
12
+ export function createPopupLayer({
13
+ additionalControls = [],
14
+ attributes,
15
+ closeOnEscape = true,
16
+ closeOnFocusOutside = false,
17
+ closeOnOutside = true,
18
+ disposables,
19
+ getController,
20
+ hasPopup,
21
+ handlesTab = false,
22
+ idPrefix,
23
+ initialOpen,
24
+ insideElements = [],
25
+ onClose,
26
+ onOpen,
27
+ popup,
28
+ returnFocus = true,
29
+ shouldCloseOnFocusOutside = () => true,
30
+ stateElement = popup,
31
+ trigger,
32
+ }) {
33
+ const document = trigger.ownerDocument;
34
+ const token = {};
35
+ const lifecycle = createControllerLifecycle('Popup');
36
+ let open = Boolean(initialOpen);
37
+ let removeFromStack = null;
38
+
39
+ const popupId = ensureId(popup, idPrefix, attributes);
40
+ const controls = [trigger, ...additionalControls];
41
+ controls.forEach((control) => {
42
+ attributes.set(control, 'aria-haspopup', hasPopup);
43
+ attributes.set(control, 'aria-controls', popupId);
44
+ });
45
+
46
+ function sync() {
47
+ controls.forEach((control) => attributes.set(control, 'aria-expanded', String(open)));
48
+ attributes.set(stateElement, 'data-state', open ? 'open' : 'closed');
49
+ attributes.set(popup, 'hidden', open ? null : '');
50
+ }
51
+
52
+ function restorePopupFocus() {
53
+ if (focusElement(trigger)) return true;
54
+
55
+ const active = document.activeElement;
56
+ if (isElement(active) && popup.contains(active) && typeof active.blur === 'function') {
57
+ active.blur();
58
+ }
59
+ return false;
60
+ }
61
+
62
+ function registerOverlay() {
63
+ return pushOverlay(document, token, {
64
+ focusScopes: [popup],
65
+ handlesTab,
66
+ onOwnerClose: () => closePopup('owner-close', { restoreFocus: false }),
67
+ onOwnerDestroy: () => getController().destroy(),
68
+ trigger,
69
+ });
70
+ }
71
+
72
+ function openPopup(reason = 'programmatic') {
73
+ return lifecycle.run('open', (checkpoint) => {
74
+ if (open) return false;
75
+
76
+ const detail = { controller: getController(), reason };
77
+ const allowed = dispatch(popup, 'ct:beforeopen', detail, true);
78
+ checkpoint();
79
+ if (!allowed) return false;
80
+
81
+ open = true;
82
+ sync();
83
+ removeFromStack = registerOverlay();
84
+ try {
85
+ onOpen?.(detail, checkpoint);
86
+ checkpoint();
87
+ } catch (error) {
88
+ if (lifecycle.isAlive) {
89
+ open = false;
90
+ sync();
91
+ removeFromStack?.();
92
+ removeFromStack = null;
93
+ if (returnFocus) restorePopupFocus();
94
+ }
95
+ throw error;
96
+ }
97
+ dispatch(popup, 'ct:open', detail);
98
+ checkpoint();
99
+ return true;
100
+ });
101
+ }
102
+
103
+ function closePopup(reason = 'programmatic', { restoreFocus = returnFocus } = {}) {
104
+ return lifecycle.run('close', (checkpoint) => {
105
+ if (!open) return false;
106
+
107
+ const detail = { controller: getController(), reason };
108
+ const allowed = dispatch(popup, 'ct:beforeclose', detail, true);
109
+ checkpoint();
110
+ if (!allowed) return false;
111
+
112
+ open = false;
113
+ sync();
114
+ removeFromStack?.();
115
+ removeFromStack = null;
116
+ if (restoreFocus) {
117
+ restorePopupFocus();
118
+ checkpoint();
119
+ }
120
+ onClose?.(detail, checkpoint);
121
+ checkpoint();
122
+ dispatch(popup, 'ct:close', detail);
123
+ checkpoint();
124
+ return true;
125
+ });
126
+ }
127
+
128
+ function togglePopup(reason = 'programmatic') {
129
+ lifecycle.assertAlive();
130
+ return open ? closePopup(reason) : openPopup(reason);
131
+ }
132
+
133
+ disposables.listen(document, 'keydown', (event) => {
134
+ if (
135
+ !open ||
136
+ !closeOnEscape ||
137
+ event.key !== 'Escape' ||
138
+ isComposingEvent(event) ||
139
+ !consumeTopOverlayEvent(event, document, token)
140
+ ) return;
141
+ event.preventDefault();
142
+ event.stopPropagation();
143
+ closePopup('escape');
144
+ }, true);
145
+
146
+ disposables.listen(document, 'pointerdown', (event) => {
147
+ if (
148
+ open &&
149
+ closeOnOutside &&
150
+ eventTargetsOutside(event, [trigger, popup, ...additionalControls, ...insideElements]) &&
151
+ consumeTopOverlayEvent(event, document, token)
152
+ ) {
153
+ closePopup('outside', { restoreFocus: false });
154
+ }
155
+ }, true);
156
+
157
+ disposables.listen(document, 'focusin', (event) => {
158
+ if (
159
+ open &&
160
+ closeOnFocusOutside &&
161
+ shouldCloseOnFocusOutside(event) &&
162
+ eventTargetsOutside(event, [trigger, popup, ...additionalControls, ...insideElements]) &&
163
+ consumeTopOverlayEvent(event, document, token)
164
+ ) {
165
+ closePopup('focus-outside', { restoreFocus: false });
166
+ }
167
+ }, true);
168
+
169
+ sync();
170
+
171
+ return {
172
+ get isOpen() {
173
+ return open;
174
+ },
175
+ open: openPopup,
176
+ close: closePopup,
177
+ toggle: togglePopup,
178
+ activateInitialOpen() {
179
+ lifecycle.assertAlive();
180
+ if (!open || removeFromStack) return;
181
+ removeFromStack = registerOverlay();
182
+ },
183
+ destroy() {
184
+ if (!lifecycle.destroy()) return;
185
+ open = false;
186
+ removeFromStack?.();
187
+ removeFromStack = null;
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,17 @@
1
+ import { createDialogController } from './internal/dialog-controller.js';
2
+
3
+ const controllers = new WeakMap();
4
+
5
+ /**
6
+ * Adds modal dialog behavior to existing Construct markup.
7
+ *
8
+ * Required DOM contract: `container` is the state/backdrop element and `dialog`
9
+ * is the focus scope carrying the dialog semantics. They may be the same element.
10
+ */
11
+ export function createModalController(options) {
12
+ return createDialogController(options, controllers, {
13
+ idPrefix: 'ct-modal',
14
+ name: 'Modal',
15
+ triggerAction: 'open',
16
+ });
17
+ }
@@ -0,0 +1,278 @@
1
+ import {
2
+ assertElement,
3
+ assertElements,
4
+ assertFunction,
5
+ assertSameDocument,
6
+ createAttributeStore,
7
+ createControllerLifecycle,
8
+ createDisposables,
9
+ focusElement,
10
+ getFocusableElements,
11
+ isDisabled,
12
+ isElement,
13
+ } from './internal/dom.js';
14
+ import { createPopupLayer } from './internal/popup-layer.js';
15
+
16
+ const controllers = new WeakMap();
17
+
18
+ function resolveFocusTarget(initialFocus, popover) {
19
+ let target = initialFocus;
20
+ if (typeof target === 'function') target = target();
21
+ if (typeof target === 'string') {
22
+ target = popover.querySelector(target);
23
+ if (!target) throw new TypeError('initialFocus selector did not match an Element inside popover.');
24
+ }
25
+ if (target == null) target = popover.querySelector('[autofocus]');
26
+ if (target == null) target = getFocusableElements(popover)[0];
27
+ if (target != null && (!isElement(target) || !popover.contains(target))) {
28
+ throw new TypeError('initialFocus must resolve to an Element inside popover.');
29
+ }
30
+ return target;
31
+ }
32
+
33
+ function validateInitialFocus(initialFocus, popover) {
34
+ if (initialFocus == null || typeof initialFocus === 'function') return;
35
+ if (typeof initialFocus === 'string') {
36
+ if (!popover.querySelector(initialFocus)) {
37
+ throw new TypeError('initialFocus selector did not match an Element inside popover.');
38
+ }
39
+ return;
40
+ }
41
+ if (!isElement(initialFocus) || !popover.contains(initialFocus)) {
42
+ throw new TypeError('initialFocus must be an Element inside popover, a selector, or a function.');
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Creates a non-modal popover controller. Click/touch activation is always
48
+ * available; focus and hover activation can be enabled independently.
49
+ */
50
+ export function createPopoverController(options) {
51
+ if (!options || typeof options !== 'object') {
52
+ throw new TypeError('Popover controller options are required.');
53
+ }
54
+
55
+ const trigger = assertElement(options.trigger, 'options.trigger');
56
+ const existing = controllers.get(trigger);
57
+ if (existing) return existing;
58
+ const popover = assertElement(options.popover, 'options.popover');
59
+ const root = assertElement(options.root ?? popover.closest('.ct-popover') ?? popover, 'options.root');
60
+ const closeButtons = options.closeButtons == null
61
+ ? [...popover.querySelectorAll('[data-ct-dismiss]')]
62
+ : assertElements(options.closeButtons, 'options.closeButtons');
63
+ if (trigger.ownerDocument !== popover.ownerDocument || trigger.ownerDocument !== root.ownerDocument) {
64
+ throw new TypeError('Popover elements must belong to the same document.');
65
+ }
66
+ assertSameDocument(closeButtons, trigger.ownerDocument, 'options.closeButtons');
67
+ assertFunction(options.onOpen, 'options.onOpen');
68
+ assertFunction(options.onClose, 'options.onClose');
69
+ validateInitialFocus(options.initialFocus, popover);
70
+
71
+ const attributes = createAttributeStore();
72
+ const lifecycle = createControllerLifecycle('Popover');
73
+ const disposables = createDisposables();
74
+ const document = trigger.ownerDocument;
75
+ const openDelay = options.openDelay ?? 150;
76
+ const closeDelay = options.closeDelay ?? 100;
77
+ if (!Number.isFinite(openDelay) || openDelay < 0 || !Number.isFinite(closeDelay) || closeDelay < 0) {
78
+ throw new RangeError('Popover delays must be non-negative finite numbers.');
79
+ }
80
+
81
+ let controller;
82
+ let timer;
83
+ let pendingFocus = false;
84
+ let pointerDownOnTrigger = false;
85
+ let ignoreNextClick = false;
86
+ let hovered = false;
87
+
88
+ const existingRole = popover.getAttribute('role');
89
+ const popupRole = ['dialog', 'grid', 'listbox', 'menu', 'tree'].includes(existingRole)
90
+ ? existingRole
91
+ : 'dialog';
92
+ attributes.set(popover, 'role', popupRole);
93
+ if (trigger.tagName === 'BUTTON' && !trigger.hasAttribute('type')) {
94
+ attributes.set(trigger, 'type', 'button');
95
+ }
96
+
97
+ function clearTimer() {
98
+ if (timer) clearTimeout(timer);
99
+ timer = undefined;
100
+ }
101
+
102
+ function focusInside(checkpoint = lifecycle.assertAlive) {
103
+ const target = resolveFocusTarget(options.initialFocus, popover);
104
+ checkpoint();
105
+ if (target) {
106
+ focusElement(target);
107
+ checkpoint();
108
+ }
109
+ }
110
+
111
+ const layer = createPopupLayer({
112
+ attributes,
113
+ closeOnEscape: options.closeOnEscape !== false,
114
+ closeOnFocusOutside: options.closeOnFocusOutside !== false,
115
+ closeOnOutside: options.closeOnOutside !== false,
116
+ disposables,
117
+ getController: () => controller,
118
+ hasPopup: popupRole,
119
+ idPrefix: 'ct-popover-content',
120
+ initialOpen: !isDisabled(trigger) && (options.open ?? root.getAttribute('data-state') === 'open'),
121
+ popup: popover,
122
+ shouldCloseOnFocusOutside: () => !hovered,
123
+ stateElement: root,
124
+ trigger,
125
+ onOpen(detail, checkpoint) {
126
+ clearTimer();
127
+ if (pendingFocus) focusInside(checkpoint);
128
+ else checkpoint();
129
+ options.onOpen?.(detail);
130
+ },
131
+ onClose(detail) {
132
+ clearTimer();
133
+ options.onClose?.(detail);
134
+ },
135
+ });
136
+
137
+ function open(
138
+ reason = 'programmatic',
139
+ { focus = Boolean(options.initialFocus || options.focusOnOpen) } = {},
140
+ ) {
141
+ return lifecycle.run('open', (checkpoint) => {
142
+ if (isDisabled(trigger)) return false;
143
+ pendingFocus = focus;
144
+ try {
145
+ const changed = layer.open(reason);
146
+ checkpoint();
147
+ return changed;
148
+ } finally {
149
+ pendingFocus = false;
150
+ }
151
+ });
152
+ }
153
+
154
+ function scheduleOpen(reason) {
155
+ clearTimer();
156
+ timer = setTimeout(() => open(reason, { focus: false }), openDelay);
157
+ }
158
+
159
+ function scheduleClose(reason) {
160
+ clearTimer();
161
+ timer = setTimeout(() => {
162
+ const active = document.activeElement;
163
+ if (hovered || trigger.contains(active) || popover.contains(active)) return;
164
+ close(reason, { restoreFocus: false });
165
+ }, closeDelay);
166
+ }
167
+
168
+ function close(reason = 'programmatic', closeOptions) {
169
+ return lifecycle.run('close', (checkpoint) => {
170
+ const changed = layer.close(reason, closeOptions);
171
+ checkpoint();
172
+ return changed;
173
+ });
174
+ }
175
+
176
+ function toggle(reason = 'programmatic') {
177
+ return lifecycle.run('toggle', (checkpoint) => {
178
+ if (isDisabled(trigger)) return false;
179
+ /* Same focus-entry default as open(): configuring initialFocus means
180
+ trigger activation must move focus in too, or keyboard users may
181
+ never reach popover content rendered elsewhere in the DOM. */
182
+ pendingFocus = Boolean(options.initialFocus || options.focusOnOpen);
183
+ try {
184
+ const changed = layer.toggle(reason);
185
+ checkpoint();
186
+ return changed;
187
+ } finally {
188
+ pendingFocus = false;
189
+ }
190
+ });
191
+ }
192
+
193
+ disposables.listen(trigger, 'pointerdown', () => {
194
+ pointerDownOnTrigger = true;
195
+ });
196
+ disposables.listen(trigger, 'click', (event) => {
197
+ event.preventDefault();
198
+ pointerDownOnTrigger = false;
199
+ if (isDisabled(trigger)) return;
200
+ if (ignoreNextClick) {
201
+ ignoreNextClick = false;
202
+ return;
203
+ }
204
+ toggle('trigger');
205
+ });
206
+ disposables.listen(trigger, 'pointercancel', () => {
207
+ pointerDownOnTrigger = false;
208
+ ignoreNextClick = false;
209
+ });
210
+ disposables.listen(trigger, 'pointerup', () => {
211
+ pointerDownOnTrigger = false;
212
+ });
213
+
214
+ if (options.openOnFocus) {
215
+ disposables.listen(trigger, 'focus', () => {
216
+ if (pointerDownOnTrigger) ignoreNextClick = true;
217
+ open('focus', { focus: false });
218
+ });
219
+ }
220
+
221
+ if (options.openOnHover) {
222
+ /* When no wrapper root exists, root falls back to the (hidden) popover,
223
+ which can never receive pointerenter — hover must then be driven by
224
+ the trigger as well. */
225
+ const hoverTargets = root.contains(trigger) ? [root] : [root, trigger];
226
+ hoverTargets.forEach((target) => {
227
+ disposables.listen(target, 'pointerenter', (event) => {
228
+ if (event.pointerType !== 'touch') {
229
+ hovered = true;
230
+ scheduleOpen('hover');
231
+ }
232
+ });
233
+ disposables.listen(target, 'pointerleave', (event) => {
234
+ if (event.pointerType !== 'touch') {
235
+ hovered = false;
236
+ scheduleClose('hover');
237
+ }
238
+ });
239
+ });
240
+ }
241
+
242
+ closeButtons.forEach((button) => {
243
+ disposables.listen(button, 'click', (event) => {
244
+ event.preventDefault();
245
+ if (!isDisabled(button)) close('dismiss');
246
+ });
247
+ });
248
+
249
+ controller = Object.freeze({
250
+ get root() {
251
+ return root;
252
+ },
253
+ get trigger() {
254
+ return trigger;
255
+ },
256
+ get popover() {
257
+ return popover;
258
+ },
259
+ get isOpen() {
260
+ return layer.isOpen;
261
+ },
262
+ open,
263
+ close,
264
+ toggle,
265
+ destroy() {
266
+ if (!lifecycle.destroy()) return;
267
+ clearTimer();
268
+ layer.destroy();
269
+ disposables.dispose();
270
+ attributes.restore();
271
+ controllers.delete(trigger);
272
+ },
273
+ });
274
+
275
+ controllers.set(trigger, controller);
276
+ layer.activateInitialOpen();
277
+ return controller;
278
+ }