@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,486 @@
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
+ getTextLabel,
17
+ isComposingEvent,
18
+ isDisabled,
19
+ nextEnabledIndex,
20
+ warnAccessibility,
21
+ } from './internal/dom.js';
22
+ import { createPopupLayer } from './internal/popup-layer.js';
23
+
24
+ const controllers = new WeakMap();
25
+
26
+ function isAvailable(option) {
27
+ if (isDisabled(option) || option.hidden || option.getAttribute('aria-hidden') === 'true') return false;
28
+ const view = option.ownerDocument.defaultView;
29
+ if (!view) return true;
30
+ const style = view.getComputedStyle(option);
31
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.visibility !== 'collapse';
32
+ }
33
+
34
+ /**
35
+ * Creates an editable ARIA combobox with virtual listbox focus, optional local
36
+ * filtering, selection, status announcements, and a separately operable toggle.
37
+ */
38
+ export function createComboboxController(options) {
39
+ if (!options || typeof options !== 'object') {
40
+ throw new TypeError('Combobox controller options are required.');
41
+ }
42
+
43
+ const input = assertElement(options.input, 'options.input');
44
+ const existing = controllers.get(input);
45
+ if (existing) return existing;
46
+ if (input.tagName !== 'INPUT' && input.tagName !== 'TEXTAREA') {
47
+ throw new TypeError('options.input must be an input or textarea element.');
48
+ }
49
+
50
+ const listbox = assertElement(options.listbox, 'options.listbox');
51
+ const root = assertElement(options.root ?? listbox.closest('.ct-combobox') ?? listbox, 'options.root');
52
+ const toggleButton = options.toggleButton == null
53
+ ? null
54
+ : assertElement(options.toggleButton, 'options.toggleButton');
55
+ const optionElements = options.options == null
56
+ ? [...listbox.querySelectorAll('[role="option"], [data-ct-option]')]
57
+ : assertElements(options.options, 'options.options');
58
+ if (!optionElements.length) throw new TypeError('Combobox controller requires at least one option.');
59
+ if ([root, listbox, toggleButton].filter(Boolean).some((element) => element.ownerDocument !== input.ownerDocument)) {
60
+ throw new TypeError('Combobox elements must belong to the same document.');
61
+ }
62
+ assertUniqueElements(optionElements, 'options.options');
63
+ assertSameDocument(optionElements, input.ownerDocument, 'options.options');
64
+ assertDescendants(optionElements, listbox, 'options.options');
65
+
66
+ const autocomplete = assertChoice(
67
+ options.autocomplete ?? 'list',
68
+ ['list', 'none'],
69
+ 'options.autocomplete',
70
+ );
71
+ const callbacks = [
72
+ 'filter',
73
+ 'formatStatus',
74
+ 'getOptionLabel',
75
+ 'getOptionValue',
76
+ 'onClose',
77
+ 'onOpen',
78
+ 'onQueryChange',
79
+ 'onValueChange',
80
+ ];
81
+ callbacks.forEach((name) => {
82
+ if (name === 'filter' && typeof options.filter === 'boolean') return;
83
+ assertFunction(options[name], `options.${name}`);
84
+ });
85
+
86
+ const attributes = createAttributeStore();
87
+ const lifecycle = createControllerLifecycle('Combobox');
88
+ const disposables = createDisposables();
89
+ const getLabel = options.getOptionLabel ?? getTextLabel;
90
+ const getValue = options.getOptionValue ?? ((option, index) =>
91
+ option.getAttribute('data-value') ?? option.getAttribute('value') ?? getLabel(option, index));
92
+ const values = optionElements.map((option, index) => String(getValue(option, index)));
93
+ if (new Set(values).size !== values.length) {
94
+ throw new TypeError('Combobox option values must be unique.');
95
+ }
96
+
97
+ const status = options.status == null ? null : assertElement(options.status, 'options.status');
98
+ if (status) assertSameDocument([status], input.ownerDocument, 'options.status');
99
+ if (!status) {
100
+ warnAccessibility(
101
+ 'Combobox: no options.status element was provided, so filter results are never ' +
102
+ 'announced to screen readers (WCAG 4.1.3). Pass a visually hidden live-region ' +
103
+ 'element via options.status.',
104
+ );
105
+ }
106
+ const originalStatusText = status?.textContent;
107
+ const originalInputValue = input.value;
108
+ const filter = options.filter === true
109
+ ? (option, query, index) => getLabel(option, index).toLocaleLowerCase().includes(query.toLocaleLowerCase())
110
+ : options.filter;
111
+ let controller;
112
+ let activeIndex = -1;
113
+ let selectedIndex = options.value == null
114
+ ? optionElements.findIndex((option) => option.getAttribute('aria-selected') === 'true')
115
+ : values.indexOf(String(options.value));
116
+ if (options.value != null && selectedIndex < 0) {
117
+ throw new RangeError(`Unknown combobox value: ${options.value}.`);
118
+ }
119
+ if (selectedIndex >= 0 && isDisabled(optionElements[selectedIndex])) selectedIndex = -1;
120
+
121
+ attributes.set(input, 'role', 'combobox');
122
+ attributes.set(input, 'aria-autocomplete', autocomplete);
123
+ attributes.set(listbox, 'role', 'listbox');
124
+ optionElements.forEach((option) => {
125
+ attributes.set(option, 'role', 'option');
126
+ attributes.set(option, 'tabindex', '-1');
127
+ ensureId(option, 'ct-combobox-option', attributes);
128
+ });
129
+ if (toggleButton && !toggleButton.hasAttribute('type') && toggleButton.tagName === 'BUTTON') {
130
+ attributes.set(toggleButton, 'type', 'button');
131
+ }
132
+ if (status) {
133
+ attributes.set(status, 'role', status.getAttribute('role') ?? 'status');
134
+ attributes.set(status, 'aria-live', status.getAttribute('aria-live') ?? 'polite');
135
+ attributes.set(status, 'aria-atomic', status.getAttribute('aria-atomic') ?? 'true');
136
+ }
137
+
138
+ function availableOptions() {
139
+ return optionElements.filter(isAvailable);
140
+ }
141
+
142
+ function isInteractive() {
143
+ return !isDisabled(input) && !input.readOnly;
144
+ }
145
+
146
+ function availableIndex(index, delta) {
147
+ const available = availableOptions();
148
+ if (!available.length) return -1;
149
+ const currentOption = optionElements[index];
150
+ const currentAvailableIndex = available.indexOf(currentOption);
151
+ const next = nextEnabledIndex(
152
+ available,
153
+ currentAvailableIndex < 0 ? (delta > 0 ? -1 : 0) : currentAvailableIndex,
154
+ delta,
155
+ true,
156
+ );
157
+ return optionElements.indexOf(available[next]);
158
+ }
159
+
160
+ function setActive(index) {
161
+ if (index < 0 || index >= optionElements.length || !isAvailable(optionElements[index])) return false;
162
+ activeIndex = index;
163
+ optionElements.forEach((option, optionIndex) => {
164
+ attributes.set(option, 'data-highlighted', optionIndex === index ? '' : null);
165
+ });
166
+ attributes.set(input, 'aria-activedescendant', optionElements[index].id);
167
+ optionElements[index].scrollIntoView?.({ block: 'nearest' });
168
+ return true;
169
+ }
170
+
171
+ function clearActive() {
172
+ activeIndex = -1;
173
+ attributes.set(input, 'aria-activedescendant', null);
174
+ optionElements.forEach((option) => attributes.set(option, 'data-highlighted', null));
175
+ }
176
+
177
+ function syncSelection() {
178
+ optionElements.forEach((option, index) => {
179
+ attributes.set(option, 'aria-selected', String(index === selectedIndex));
180
+ });
181
+ }
182
+
183
+ function updateStatus(checkpoint = lifecycle.assertAlive) {
184
+ if (!status) return;
185
+ const count = availableOptions().length;
186
+ const message = options.formatStatus?.(count, input.value);
187
+ checkpoint();
188
+ status.textContent = message ?? `${count} result${count === 1 ? '' : 's'} available`;
189
+ }
190
+
191
+ function applyFilterRaw(checkpoint = lifecycle.assertAlive) {
192
+ if (filter) {
193
+ optionElements.forEach((option, index) => {
194
+ const visible = Boolean(filter(option, input.value, index));
195
+ checkpoint();
196
+ attributes.set(option, 'hidden', visible ? null : '');
197
+ });
198
+ }
199
+ updateStatus(checkpoint);
200
+
201
+ if (activeIndex >= 0 && !isAvailable(optionElements[activeIndex])) clearActive();
202
+ return availableOptions();
203
+ }
204
+
205
+ function refresh() {
206
+ const result = lifecycle.run('refresh', (checkpoint) => applyFilterRaw(checkpoint));
207
+ return result === false ? availableOptions() : result;
208
+ }
209
+
210
+ /* Which option receives the visual highlight when the listbox opens:
211
+ 'default' = selected option or first available, 'last' = last available
212
+ (ArrowUp open per APG), 'none' = open without moving visual focus
213
+ (Alt+ArrowDown per APG). */
214
+ let pendingOpenFocus = 'default';
215
+
216
+ const layer = createPopupLayer({
217
+ additionalControls: toggleButton ? [toggleButton] : [],
218
+ attributes,
219
+ closeOnEscape: options.closeOnEscape !== false,
220
+ closeOnFocusOutside: true,
221
+ closeOnOutside: options.closeOnOutside !== false,
222
+ disposables,
223
+ getController: () => controller,
224
+ hasPopup: 'listbox',
225
+ handlesTab: true,
226
+ idPrefix: 'ct-combobox-listbox',
227
+ initialOpen: isInteractive() && (options.open ?? root.getAttribute('data-state') === 'open'),
228
+ popup: listbox,
229
+ returnFocus: false,
230
+ stateElement: root,
231
+ trigger: input,
232
+ onOpen(detail, checkpoint) {
233
+ const available = applyFilterRaw();
234
+ let initial = -1;
235
+ if (pendingOpenFocus === 'last') {
236
+ initial = optionElements.indexOf(available.at(-1));
237
+ } else if (pendingOpenFocus === 'default') {
238
+ initial = selectedIndex >= 0 && isAvailable(optionElements[selectedIndex])
239
+ ? selectedIndex
240
+ : optionElements.indexOf(available[0]);
241
+ }
242
+ pendingOpenFocus = 'default';
243
+ if (initial >= 0) setActive(initial);
244
+ focusElement(input);
245
+ checkpoint();
246
+ options.onOpen?.(detail);
247
+ },
248
+ onClose(detail) {
249
+ clearActive();
250
+ options.onClose?.(detail);
251
+ },
252
+ });
253
+
254
+ function open(reason = 'programmatic') {
255
+ return lifecycle.run('open', (checkpoint) => {
256
+ if (!isInteractive()) return false;
257
+ const changed = layer.open(reason);
258
+ checkpoint();
259
+ return changed;
260
+ });
261
+ }
262
+
263
+ function commitSelectionRaw(
264
+ index,
265
+ reason,
266
+ { close = true, updateInput = true } = {},
267
+ checkpoint = lifecycle.assertAlive,
268
+ ) {
269
+ if (index < -1 || index >= optionElements.length) {
270
+ throw new RangeError('Combobox option is not managed by this controller.');
271
+ }
272
+ if (index >= 0 && isDisabled(optionElements[index])) return false;
273
+ if (index === selectedIndex) {
274
+ if (close && layer.isOpen) layer.close('select', { restoreFocus: false });
275
+ return false;
276
+ }
277
+
278
+ const detail = {
279
+ controller,
280
+ index,
281
+ option: index >= 0 ? optionElements[index] : null,
282
+ previousValue: selectedIndex >= 0 ? values[selectedIndex] : null,
283
+ reason,
284
+ value: index >= 0 ? values[index] : null,
285
+ };
286
+ const allowed = dispatch(listbox, 'ct:beforevaluechange', detail, true);
287
+ checkpoint();
288
+ if (!allowed) return false;
289
+
290
+ selectedIndex = index;
291
+ syncSelection();
292
+ if (index >= 0 && updateInput && options.setInputValue !== false) {
293
+ const label = getLabel(optionElements[index], index);
294
+ checkpoint();
295
+ input.value = label;
296
+ }
297
+ options.onValueChange?.(detail);
298
+ checkpoint();
299
+ dispatch(listbox, 'ct:valuechange', detail);
300
+ checkpoint();
301
+ if (close && layer.isOpen) layer.close('select', { restoreFocus: false });
302
+ return true;
303
+ }
304
+
305
+ function select(target, reason = 'programmatic') {
306
+ return lifecycle.run('select', (checkpoint) => {
307
+ const index = typeof target === 'number' ? target : optionElements.indexOf(target);
308
+ if (!Number.isInteger(index) || index < 0 || index >= optionElements.length) {
309
+ throw new RangeError('Combobox option is not managed by this controller.');
310
+ }
311
+ const changed = commitSelectionRaw(index, reason, undefined, checkpoint);
312
+ checkpoint();
313
+ focusElement(input);
314
+ return changed;
315
+ });
316
+ }
317
+
318
+ function setValue(value, reason = 'programmatic') {
319
+ return lifecycle.run('set value', (checkpoint) => {
320
+ const index = value == null ? -1 : values.indexOf(String(value));
321
+ if (value != null && index < 0) throw new RangeError(`Unknown combobox value: ${value}.`);
322
+ return commitSelectionRaw(index, reason, { close: false }, checkpoint);
323
+ });
324
+ }
325
+
326
+ function onInput() {
327
+ if (!isInteractive()) return;
328
+ lifecycle.run('input', (checkpoint) => {
329
+ let selectionChanged = false;
330
+ if (options.clearSelectionOnInput !== false && selectedIndex >= 0) {
331
+ const selectedLabel = getLabel(optionElements[selectedIndex], selectedIndex);
332
+ checkpoint();
333
+ selectionChanged = input.value !== selectedLabel;
334
+ }
335
+ if (selectionChanged) {
336
+ commitSelectionRaw(-1, 'input', { close: false, updateInput: false }, checkpoint);
337
+ }
338
+
339
+ const available = applyFilterRaw(checkpoint);
340
+ const detail = { controller, query: input.value, results: [...available] };
341
+ options.onQueryChange?.(detail);
342
+ checkpoint();
343
+ dispatch(input, 'ct:querychange', detail);
344
+ checkpoint();
345
+ if (options.openOnInput !== false) layer.open('input');
346
+ checkpoint();
347
+ if (layer.isOpen && available.length) setActive(optionElements.indexOf(available[0]));
348
+ return true;
349
+ });
350
+ }
351
+
352
+ function onInputKeyDown(event) {
353
+ if (!isInteractive()) return;
354
+ /* IME composition: Enter commits composed text and arrows navigate
355
+ candidates — the widget must not hijack them. */
356
+ if (isComposingEvent(event)) return;
357
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
358
+ event.preventDefault();
359
+ /* APG: Alt+ArrowDown opens without moving visual focus,
360
+ Alt+ArrowUp closes and keeps the current value. */
361
+ if (event.altKey) {
362
+ if (event.key === 'ArrowDown') {
363
+ if (!layer.isOpen) {
364
+ pendingOpenFocus = 'none';
365
+ open('keyboard');
366
+ }
367
+ } else if (layer.isOpen) {
368
+ close('keyboard', { restoreFocus: false });
369
+ }
370
+ return;
371
+ }
372
+ if (!layer.isOpen) {
373
+ /* APG: ArrowUp opens with visual focus on the last option. */
374
+ pendingOpenFocus = event.key === 'ArrowUp' ? 'last' : 'default';
375
+ open('keyboard');
376
+ return;
377
+ }
378
+ const next = availableIndex(activeIndex, event.key === 'ArrowDown' ? 1 : -1);
379
+ if (next >= 0) setActive(next);
380
+ return;
381
+ }
382
+
383
+ if (event.key === 'Enter' && layer.isOpen && activeIndex >= 0) {
384
+ event.preventDefault();
385
+ select(activeIndex, 'keyboard');
386
+ } else if (event.key === 'Tab' && layer.isOpen) {
387
+ close('tab', { restoreFocus: false });
388
+ }
389
+ }
390
+
391
+ disposables.listen(input, 'input', onInput);
392
+ disposables.listen(input, 'keydown', onInputKeyDown);
393
+ if (options.openOnFocus !== false) {
394
+ disposables.listen(input, 'focus', () => open('focus'));
395
+ }
396
+ if (toggleButton) {
397
+ disposables.listen(toggleButton, 'mousedown', (event) => event.preventDefault());
398
+ disposables.listen(toggleButton, 'click', (event) => {
399
+ event.preventDefault();
400
+ if (!isInteractive()) return;
401
+ toggle('toggle');
402
+ focusElement(input);
403
+ });
404
+ }
405
+ optionElements.forEach((option, index) => {
406
+ disposables.listen(option, 'pointerdown', (event) => {
407
+ if (event.pointerType !== 'touch') event.preventDefault();
408
+ });
409
+ disposables.listen(option, 'pointermove', (event) => {
410
+ if (event.pointerType !== 'touch' && isInteractive()) setActive(index);
411
+ });
412
+ disposables.listen(option, 'click', (event) => {
413
+ event.preventDefault();
414
+ if (isInteractive() && !isDisabled(option)) select(index, 'pointer');
415
+ });
416
+ });
417
+
418
+ function close(reason = 'programmatic', closeOptions) {
419
+ return lifecycle.run('close', (checkpoint) => {
420
+ const changed = layer.close(reason, closeOptions);
421
+ checkpoint();
422
+ return changed;
423
+ });
424
+ }
425
+
426
+ function toggle(reason = 'programmatic') {
427
+ return lifecycle.run('toggle', (checkpoint) => {
428
+ if (!isInteractive()) return false;
429
+ const changed = layer.toggle(reason);
430
+ checkpoint();
431
+ return changed;
432
+ });
433
+ }
434
+
435
+ controller = Object.freeze({
436
+ get root() {
437
+ return root;
438
+ },
439
+ get input() {
440
+ return input;
441
+ },
442
+ get listbox() {
443
+ return listbox;
444
+ },
445
+ get options() {
446
+ return [...optionElements];
447
+ },
448
+ get isOpen() {
449
+ return layer.isOpen;
450
+ },
451
+ get query() {
452
+ return input.value;
453
+ },
454
+ get value() {
455
+ return selectedIndex >= 0 ? values[selectedIndex] : null;
456
+ },
457
+ open,
458
+ close,
459
+ toggle,
460
+ select,
461
+ setValue,
462
+ refresh,
463
+ destroy() {
464
+ if (!lifecycle.destroy()) return;
465
+ layer.destroy();
466
+ disposables.dispose();
467
+ attributes.restore();
468
+ input.value = originalInputValue;
469
+ if (status) status.textContent = originalStatusText;
470
+ controllers.delete(input);
471
+ },
472
+ });
473
+
474
+ controllers.set(input, controller);
475
+ syncSelection();
476
+ refresh();
477
+ layer.activateInitialOpen();
478
+ if (layer.isOpen) {
479
+ const available = availableOptions();
480
+ const initial = selectedIndex >= 0 && isAvailable(optionElements[selectedIndex])
481
+ ? selectedIndex
482
+ : optionElements.indexOf(available[0]);
483
+ if (initial >= 0) setActive(initial);
484
+ }
485
+ return controller;
486
+ }
@@ -0,0 +1,18 @@
1
+ import { createDialogController } from './internal/dialog-controller.js';
2
+
3
+ const controllers = new WeakMap();
4
+
5
+ /**
6
+ * Adds modal drawer behavior to existing Construct markup.
7
+ *
8
+ * Drawers intentionally share the modal focus, dismissal, and nested scroll-lock
9
+ * contract. Their trigger toggles by default, which can be overridden with
10
+ * `triggerAction: 'open'`.
11
+ */
12
+ export function createDrawerController(options) {
13
+ return createDialogController(options, controllers, {
14
+ idPrefix: 'ct-drawer',
15
+ name: 'Drawer',
16
+ triggerAction: 'toggle',
17
+ });
18
+ }