@oneli8/core 1.0.0-beta.5 → 1.0.0-beta.7

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 (81) hide show
  1. package/README.md +19 -7
  2. package/ai-context.json +170 -3
  3. package/package.json +18 -3
  4. package/src/atoms/index.d.ts +1 -0
  5. package/src/atoms/index.js +1 -0
  6. package/src/atoms/navigation-badge/index.d.ts +1 -0
  7. package/src/atoms/navigation-badge/index.js +1 -0
  8. package/src/atoms/navigation-badge/navigation-badge.css +35 -0
  9. package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
  10. package/src/atoms/navigation-badge/navigation-badge.js +21 -0
  11. package/src/atoms/selection-indicator/selection-indicator.css +13 -4
  12. package/src/materials/gem/gem.css +52 -5
  13. package/src/molecules/choice-chip/choice-chip.css +214 -0
  14. package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
  15. package/src/molecules/choice-chip/choice-chip.js +170 -0
  16. package/src/molecules/choice-chip/index.d.ts +2 -0
  17. package/src/molecules/choice-chip/index.js +10 -0
  18. package/src/molecules/form-message/form-message.css +46 -0
  19. package/src/molecules/form-message/form-message.d.ts +23 -0
  20. package/src/molecules/form-message/form-message.js +73 -0
  21. package/src/molecules/form-message/index.d.ts +1 -0
  22. package/src/molecules/form-message/index.js +1 -0
  23. package/src/molecules/index.d.ts +6 -0
  24. package/src/molecules/index.js +6 -0
  25. package/src/molecules/link/index.d.ts +1 -0
  26. package/src/molecules/link/index.js +1 -0
  27. package/src/molecules/link/link.css +114 -0
  28. package/src/molecules/link/link.d.ts +27 -0
  29. package/src/molecules/link/link.js +76 -0
  30. package/src/molecules/navigation-item/index.js +1 -0
  31. package/src/molecules/navigation-item/navigation-item.css +153 -0
  32. package/src/molecules/navigation-item/navigation-item.js +95 -0
  33. package/src/molecules/select/index.js +1 -0
  34. package/src/molecules/select/select.css +31 -0
  35. package/src/molecules/select/select.js +127 -0
  36. package/src/molecules/selection-option/selection-option.js +4 -4
  37. package/src/molecules/text-field/index.d.ts +1 -0
  38. package/src/molecules/text-field/index.js +5 -0
  39. package/src/molecules/text-field/text-field.css +266 -0
  40. package/src/molecules/text-field/text-field.d.ts +69 -0
  41. package/src/molecules/text-field/text-field.js +234 -0
  42. package/src/molecules/token/index.d.ts +2 -0
  43. package/src/molecules/token/index.js +1 -0
  44. package/src/molecules/token/token.css +63 -0
  45. package/src/molecules/token/token.d.ts +35 -0
  46. package/src/molecules/token/token.js +83 -0
  47. package/src/organisms/choice-picker/choice-picker.css +98 -0
  48. package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
  49. package/src/organisms/choice-picker/choice-picker.js +201 -0
  50. package/src/organisms/choice-picker/index.d.ts +1 -0
  51. package/src/organisms/choice-picker/index.js +1 -0
  52. package/src/organisms/combobox/combobox.css +40 -0
  53. package/src/organisms/combobox/combobox.js +309 -0
  54. package/src/organisms/combobox/index.js +5 -0
  55. package/src/organisms/index.d.ts +7 -0
  56. package/src/organisms/index.js +7 -0
  57. package/src/organisms/multi-select-field/index.d.ts +1 -0
  58. package/src/organisms/multi-select-field/index.js +1 -0
  59. package/src/organisms/multi-select-field/multi-select-field.css +76 -0
  60. package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
  61. package/src/organisms/multi-select-field/multi-select-field.js +317 -0
  62. package/src/organisms/navigation-keys.js +58 -0
  63. package/src/organisms/segmented-control/index.d.ts +1 -0
  64. package/src/organisms/segmented-control/index.js +1 -0
  65. package/src/organisms/segmented-control/segmented-control.css +113 -0
  66. package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
  67. package/src/organisms/segmented-control/segmented-control.js +163 -0
  68. package/src/organisms/selection-popup/index.js +1 -0
  69. package/src/organisms/selection-popup/selection-popup.css +90 -0
  70. package/src/organisms/selection-popup/selection-popup.js +100 -0
  71. package/src/organisms/tab-bar/index.d.ts +1 -0
  72. package/src/organisms/tab-bar/index.js +1 -0
  73. package/src/organisms/tab-bar/tab-bar.css +45 -0
  74. package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
  75. package/src/organisms/tab-bar/tab-bar.js +104 -0
  76. package/src/organisms/tabs/index.d.ts +1 -0
  77. package/src/organisms/tabs/index.js +1 -0
  78. package/src/organisms/tabs/tabs.css +53 -0
  79. package/src/organisms/tabs/tabs.d.ts +48 -0
  80. package/src/organisms/tabs/tabs.js +152 -0
  81. package/styles.css +18 -2
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Oneli8 · Combobox — headless behavior.
3
+ *
4
+ * Figma (883:2159): "Reuses approved Text Field, Selection Popup and governed
5
+ * icon atoms", and "Expanded remains Boolean" so the design does not explode
6
+ * into a variant per state.
7
+ *
8
+ * The contract is unusually specific about what code owns here, and every one
9
+ * of these is implemented rather than described:
10
+ *
11
+ * "Editable Combobox uses a native single-line text input as its editing
12
+ * surface" — so the caret, selection, composition, dictation, undo and paste
13
+ * all keep working.
14
+ * "DOM focus remains on the Combobox and active option is communicated with
15
+ * aria-activedescendant. Popup descendants are excluded from the page Tab
16
+ * sequence."
17
+ * "Current value, active option and keyboard focus are distinct states."
18
+ * "Escape closes a custom popup without committing the preview and restores
19
+ * the prior committed value."
20
+ * "Leaving a constrained Combobox with unmatched text does not silently
21
+ * commit an invalid option."
22
+ * "During IME composition, text input is not prematurely committed and
23
+ * navigation commands do not corrupt the composition string."
24
+ */
25
+ import { renderIcon } from '../../atoms/icon/index.js';
26
+ import { renderFormMessage } from '../../molecules/form-message/index.js';
27
+ import { renderSelectionPopup } from '../selection-popup/index.js';
28
+
29
+ export const OL8_COMBOBOX_SIZES = ['compact', 'standard', 'comfortable', 'large'];
30
+ export const OL8_COMBOBOX_APPEARANCES = ['outline', 'filled'];
31
+ export const OL8_COMBOBOX_MATERIALS = ['regular', 'gem'];
32
+
33
+ /** Constrained holds one of the options; suggestive may hold authored text. */
34
+ export const OL8_VALUE_POLICIES = ['constrained', 'suggestive'];
35
+ export const OL8_AUTOCOMPLETE_MODES = ['none', 'list-manual', 'list-automatic'];
36
+ export const OL8_FILTER_SOURCES = ['local', 'remote', 'external'];
37
+
38
+ let sequence = 0;
39
+
40
+ /**
41
+ * @param {{
42
+ * label:string, options:Array<{value:string,label:string,description?:string,leadingIcon?:string,disabled?:boolean,group?:string}>,
43
+ * id?:string, name?:string, inputValue?:string, value?:string,
44
+ * size?:string, appearance?:string, material?:string,
45
+ * open?:boolean, active?:string, showLabel?:boolean,
46
+ * required?:boolean, disabled?:boolean, invalid?:boolean, readOnly?:boolean,
47
+ * placeholder?:string, leadingIcon?:string, clearable?:boolean,
48
+ * instruction?:string, message?:string, messageTone?:string,
49
+ * valuePolicy?:string, autocomplete?:string, filter?:string,
50
+ * loading?:boolean, emptyText?:string, retrievalError?:string,
51
+ * popupWidth?:string, className?:string,
52
+ * }} options
53
+ */
54
+ export function renderCombobox(options) {
55
+ const {
56
+ label, options: items = [], id = `ol8-combobox-${++sequence}`, name,
57
+ inputValue, value, size = 'comfortable', appearance = 'outline', material = 'regular',
58
+ open = false, active, showLabel = true,
59
+ required = false, disabled = false, invalid = false, readOnly = false,
60
+ placeholder, leadingIcon, clearable = false,
61
+ instruction, message, messageTone = 'critical',
62
+ valuePolicy = 'constrained', autocomplete = 'list-manual', filter = 'local',
63
+ loading = false, emptyText, retrievalError,
64
+ popupWidth = 'anchor', className,
65
+ } = options ?? {};
66
+
67
+ assertCombobox({ label, size, appearance, material, valuePolicy, autocomplete, filter, required, clearable, readOnly, invalid });
68
+
69
+ const popupId = `${id}-listbox`;
70
+ const instructionId = instruction ? `${id}-instruction` : null;
71
+ const messageId = message ? `${id}-message` : null;
72
+ const describedBy = [instructionId, messageId].filter(Boolean).join(' ');
73
+ const popupSize = size === 'large' ? 'large' : 'standard';
74
+
75
+ // Four distinct conditions, never collapsed into one "nothing here".
76
+ const status = loading ? 'loading'
77
+ : retrievalError ? 'error'
78
+ : (items.length === 0 && emptyText) ? 'empty'
79
+ : 'none';
80
+ const statusText = loading ? 'Loading' : retrievalError || emptyText;
81
+
82
+ const attrs = [
83
+ `class="ol8-field ol8-combobox${className ? ` ${className}` : ''}"`,
84
+ `data-ol8-size="${size}"`,
85
+ `data-ol8-appearance="${appearance}"`,
86
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
87
+ disabled ? 'data-ol8-disabled="true"' : '',
88
+ readOnly ? 'data-ol8-readonly="true"' : '',
89
+ invalid ? 'data-ol8-invalid="true"' : '',
90
+ `data-ol8-value-policy="${valuePolicy}"`,
91
+ `data-ol8-filter="${filter}"`,
92
+ value !== undefined ? `data-ol8-committed="${escapeAttr(value)}"` : '',
93
+ ].filter(Boolean).join(' ');
94
+
95
+ return `<div ${attrs}>` +
96
+ (showLabel
97
+ ? `<div class="ol8-field__label-row">` +
98
+ `<label class="ol8-field__label" for="${escapeAttr(id)}">${escapeText(label)}</label>` +
99
+ (required ? `<span class="ol8-field__requirement" aria-hidden="true">*</span>` : '') +
100
+ `</div>`
101
+ : '') +
102
+ `<div class="ol8-field__control-stack">` +
103
+ `<div class="ol8-field__control">` +
104
+ (leadingIcon ? renderIcon(leadingIcon, { size: 18, className: 'ol8-field__leading-icon' }) : '') +
105
+ // A real text input, so every platform text service keeps working.
106
+ `<input class="ol8-field__input ol8-combobox__input" id="${escapeAttr(id)}" type="text"` +
107
+ ` role="combobox" aria-expanded="${open}" aria-controls="${popupId}"` +
108
+ ` aria-autocomplete="${autocomplete === 'none' ? 'none' : 'list'}"` +
109
+ ' autocomplete="off"' +
110
+ (active ? ` aria-activedescendant="${popupId}-option-${escapeAttr(active)}"` : '') +
111
+ (name ? ` name="${escapeAttr(name)}"` : '') +
112
+ (inputValue !== undefined ? ` value="${escapeAttr(inputValue)}"` : '') +
113
+ (placeholder ? ` placeholder="${escapeAttr(placeholder)}"` : '') +
114
+ (required ? ' required' : '') +
115
+ (disabled ? ' disabled' : '') +
116
+ (readOnly ? ' readonly' : '') +
117
+ (invalid ? ' aria-invalid="true"' : '') +
118
+ (invalid && messageId ? ` aria-errormessage="${messageId}"` : '') +
119
+ (describedBy ? ` aria-describedby="${escapeAttr(describedBy)}"` : '') +
120
+ (showLabel ? '' : ` aria-label="${escapeAttr(label)}"`) +
121
+ `>` +
122
+ // "Clearing is an explicit named action", so it is a real button with
123
+ // a real name rather than an unlabelled cross.
124
+ (clearable
125
+ ? `<button class="ol8-combobox__clear" type="button" tabindex="-1"` +
126
+ `${disabled ? ' disabled' : ''} aria-label="Clear ${escapeAttr(label)}">` +
127
+ renderIcon('clear', { size: 18 }) + `</button>`
128
+ : '') +
129
+ // The indicator is not a second tab stop: "popup indicator and listbox
130
+ // options are not additional page Tab stops".
131
+ `<span class="ol8-combobox__disclosure" aria-hidden="true">` +
132
+ renderIcon(open ? 'chevron-up' : 'chevron-down', { size: 18 }) + `</span>` +
133
+ `</div>` +
134
+ `</div>` +
135
+ (instruction ? `<div class="ol8-field__instruction" id="${instructionId}">${escapeText(instruction)}</div>` : '') +
136
+ (message
137
+ ? `<div class="ol8-field__supporting">${renderFormMessage(message, { tone: messageTone, id: messageId })}</div>`
138
+ : '') +
139
+ `<div class="ol8-combobox__popup"${open ? '' : ' hidden'}>` +
140
+ renderSelectionPopup({
141
+ id: popupId, label, options: items, selected: value, active,
142
+ width: popupWidth, size: popupSize, status, statusText,
143
+ }) +
144
+ `</div>` +
145
+ `</div>`;
146
+ }
147
+
148
+ /**
149
+ * Wires an existing `.ol8-combobox`. Idempotent.
150
+ *
151
+ * Focus never leaves the input. The active option is a virtual cursor carried
152
+ * by aria-activedescendant, which is what lets a screen reader read the option
153
+ * while the caret stays where the person is typing.
154
+ */
155
+ export function hydrateComboboxes(root = document) {
156
+ let count = 0;
157
+ for (const box of root.querySelectorAll('.ol8-combobox')) {
158
+ if (box.dataset.ol8Wired === 'true') continue;
159
+ box.dataset.ol8Wired = 'true';
160
+ count += 1;
161
+
162
+ const input = box.querySelector('.ol8-combobox__input');
163
+ const popup = box.querySelector('.ol8-combobox__popup');
164
+ const listbox = box.querySelector('[role="listbox"]');
165
+ if (!input || !popup || !listbox) continue;
166
+
167
+ // The value that was actually committed, so Escape and an unmatched blur
168
+ // have something truthful to restore.
169
+ let committed = box.dataset.ol8Committed ?? '';
170
+ let committedText = input.value;
171
+ let composing = false;
172
+
173
+ const optionsOf = () => [...listbox.querySelectorAll('[role="option"]:not([aria-disabled="true"])')];
174
+ const activeOption = () => listbox.querySelector('[data-ol8-active="true"]');
175
+
176
+ const setOpen = (open) => {
177
+ popup.hidden = !open;
178
+ input.setAttribute('aria-expanded', String(open));
179
+ if (!open) setActive(null);
180
+ };
181
+
182
+ const setActive = (option) => {
183
+ for (const other of listbox.querySelectorAll('[role="option"]')) delete other.dataset.ol8Active;
184
+ if (!option) { input.removeAttribute('aria-activedescendant'); return; }
185
+ option.dataset.ol8Active = 'true';
186
+ input.setAttribute('aria-activedescendant', option.id);
187
+ option.scrollIntoView({ block: 'nearest' });
188
+ };
189
+
190
+ const commit = (option) => {
191
+ if (!option) return;
192
+ committed = option.dataset.ol8Value ?? '';
193
+ committedText = option.querySelector('.ol8-option__label')?.textContent ?? option.textContent.trim();
194
+ input.value = committedText;
195
+ box.dataset.ol8Committed = committed;
196
+ for (const other of listbox.querySelectorAll('[role="option"]')) {
197
+ other.setAttribute('aria-selected', String(other === option));
198
+ }
199
+ setOpen(false);
200
+ box.dispatchEvent(new CustomEvent('ol8:comboboxcommit', { bubbles: true, detail: { value: committed } }));
201
+ };
202
+
203
+ const move = (step, edge) => {
204
+ const options = optionsOf();
205
+ if (options.length === 0) return;
206
+ if (popup.hidden) setOpen(true);
207
+ const at = options.indexOf(activeOption());
208
+ let next;
209
+ if (edge === 'start') next = 0;
210
+ else if (edge === 'end') next = options.length - 1;
211
+ else if (at === -1) next = step > 0 ? 0 : options.length - 1;
212
+ else next = (at + step + options.length) % options.length;
213
+ setActive(options[next]);
214
+ };
215
+
216
+ // "During IME composition, text input is not prematurely committed and
217
+ // navigation commands do not corrupt the composition string."
218
+ input.addEventListener('compositionstart', () => { composing = true; });
219
+ input.addEventListener('compositionend', () => { composing = false; });
220
+
221
+ input.addEventListener('keydown', (event) => {
222
+ if (composing || event.isComposing) return;
223
+
224
+ // Alt with an arrow opens or closes without changing the value.
225
+ if (event.altKey && event.key === 'ArrowDown') { event.preventDefault(); setOpen(true); return; }
226
+ if (event.altKey && event.key === 'ArrowUp') { event.preventDefault(); setOpen(false); return; }
227
+
228
+ switch (event.key) {
229
+ case 'ArrowDown': event.preventDefault(); move(1); break;
230
+ case 'ArrowUp': event.preventDefault(); move(-1); break;
231
+ case 'Home': if (!popup.hidden) { event.preventDefault(); move(0, 'start'); } break;
232
+ case 'End': if (!popup.hidden) { event.preventDefault(); move(0, 'end'); } break;
233
+ case 'Enter':
234
+ if (!popup.hidden && activeOption()) { event.preventDefault(); commit(activeOption()); }
235
+ break;
236
+ case 'Escape':
237
+ // Closes without committing the preview, and puts back what was there.
238
+ event.preventDefault();
239
+ if (popup.hidden) return;
240
+ input.value = committedText;
241
+ setOpen(false);
242
+ break;
243
+ default: break;
244
+ }
245
+ });
246
+
247
+ input.addEventListener('input', () => {
248
+ if (composing) return;
249
+ if (popup.hidden) setOpen(true);
250
+ box.dispatchEvent(new CustomEvent('ol8:comboboxinput', { bubbles: true, detail: { inputValue: input.value } }));
251
+ });
252
+
253
+ input.addEventListener('blur', () => {
254
+ // "Leaving a constrained Combobox with unmatched text does not silently
255
+ // commit an invalid option." It restores what was actually committed.
256
+ if (box.dataset.ol8ValuePolicy === 'constrained' && input.value !== committedText) {
257
+ input.value = committedText;
258
+ box.dispatchEvent(new CustomEvent('ol8:comboboxrestore', { bubbles: true, detail: { value: committed } }));
259
+ }
260
+ setOpen(false);
261
+ });
262
+
263
+ // Pointer activation commits, and mousedown is prevented so focus never
264
+ // leaves the input on the way there.
265
+ listbox.addEventListener('mousedown', (event) => event.preventDefault());
266
+ listbox.addEventListener('click', (event) => {
267
+ const option = event.target.closest('[role="option"]');
268
+ if (option && option.getAttribute('aria-disabled') !== 'true') commit(option);
269
+ });
270
+
271
+ box.querySelector('.ol8-combobox__disclosure')?.addEventListener('mousedown', (event) => {
272
+ event.preventDefault();
273
+ input.focus();
274
+ setOpen(popup.hidden);
275
+ });
276
+
277
+ box.querySelector('.ol8-combobox__clear')?.addEventListener('click', () => {
278
+ input.value = '';
279
+ committed = '';
280
+ committedText = '';
281
+ box.dataset.ol8Committed = '';
282
+ input.focus();
283
+ box.dispatchEvent(new CustomEvent('ol8:comboboxclear', { bubbles: true }));
284
+ });
285
+ }
286
+ return count;
287
+ }
288
+
289
+ function assertCombobox({ label, size, appearance, material, valuePolicy, autocomplete, filter, required, clearable, readOnly, invalid }) {
290
+ if (typeof label !== 'string' || label === '') {
291
+ throw new Error('[ol8] Combobox requires a label, and its accessible name stays distinct from its value.');
292
+ }
293
+ if (!OL8_COMBOBOX_SIZES.includes(size)) throw new Error(`[ol8] unknown combobox size "${size}"`);
294
+ if (!OL8_COMBOBOX_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown combobox appearance "${appearance}"`);
295
+ if (!OL8_COMBOBOX_MATERIALS.includes(material)) throw new Error(`[ol8] unknown combobox material "${material}"`);
296
+ if (!OL8_VALUE_POLICIES.includes(valuePolicy)) throw new Error(`[ol8] unknown value policy "${valuePolicy}"`);
297
+ if (!OL8_AUTOCOMPLETE_MODES.includes(autocomplete)) throw new Error(`[ol8] unknown autocomplete mode "${autocomplete}"`);
298
+ if (!OL8_FILTER_SOURCES.includes(filter)) throw new Error(`[ol8] unknown filter source "${filter}"`);
299
+ // "clearable for an optional value only."
300
+ if (clearable && required) {
301
+ throw new Error('[ol8] a required Combobox is not clearable, because clearing it would leave a value the form cannot accept.');
302
+ }
303
+ if (readOnly && invalid) {
304
+ throw new Error('[ol8] a read only Combobox cannot be invalid, for the same reason a read only field cannot be.');
305
+ }
306
+ }
307
+
308
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
309
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1,5 @@
1
+ export {
2
+ renderCombobox, hydrateComboboxes,
3
+ OL8_COMBOBOX_SIZES, OL8_COMBOBOX_APPEARANCES, OL8_COMBOBOX_MATERIALS,
4
+ OL8_VALUE_POLICIES, OL8_AUTOCOMPLETE_MODES, OL8_FILTER_SOURCES,
5
+ } from './combobox.js';
@@ -1 +1,8 @@
1
1
  export * from './choice-group/index.js';
2
+ export * from './tabs/index.js';
3
+ export * from './segmented-control/index.js';
4
+ export * from './tab-bar/index.js';
5
+ export * from './selection-popup/index.js';
6
+ export * from './combobox/index.js';
7
+ export * from './multi-select-field/index.js';
8
+ export * from './choice-picker/index.js';
@@ -1 +1,8 @@
1
1
  export * from './choice-group/index.js';
2
+ export * from './tabs/index.js';
3
+ export * from './segmented-control/index.js';
4
+ export * from './tab-bar/index.js';
5
+ export * from './selection-popup/index.js';
6
+ export * from './combobox/index.js';
7
+ export * from './multi-select-field/index.js';
8
+ export * from './choice-picker/index.js';
@@ -0,0 +1 @@
1
+ export * from './multi-select-field.js';
@@ -0,0 +1 @@
1
+ export * from './multi-select-field.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Oneli8 · Multi-select Field (ORGANISM)
3
+ * Figma: Organism / Multi-select Field / Outline 904:2507 (16 variants)
4
+ * Organism / Multi-select Field / Filled 978:4472 (16 variants)
5
+ *
6
+ * Almost everything here is inherited rather than declared. The field shell,
7
+ * its four sizes, both appearances, the Gem material and the focus treatment
8
+ * all come from the Text Field stylesheet, because Figma builds this organism
9
+ * from the canonical Text Field owner and only swaps that owner between the
10
+ * Outline and Filled sets. What is left is the one thing a Text Field has never
11
+ * had to do: let committed values and the editable input share a run that
12
+ * wraps.
13
+ */
14
+
15
+ /* Figma's root is a vertical stack: the field composition, then the popup,
16
+ twelve apart. That gap is now bound in Figma to Spacing / Stack / Related. */
17
+ .ol8-multiselect {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: var(--ol8-spacing-stack-related);
21
+ inline-size: 100%;
22
+ }
23
+
24
+ /* A Text Field pins this stack to one row's height, because a single line
25
+ input never needs more. A multi value field does: releasing the stack is what
26
+ lets the control below it grow, and without it the tokens simply spill out of
27
+ the boundary they are supposed to sit inside. */
28
+ .ol8-multiselect__field > .ol8-field__control-stack {
29
+ block-size: auto;
30
+ min-block-size: var(--_height);
31
+ }
32
+
33
+ /* The control grows instead of scrolling or shrinking its contents. The
34
+ contract is explicit: the field "starts at its approved Text Field minimum
35
+ height and grows in 6px or 12px-aligned increments as tokens wrap" and "never
36
+ compresses tokens to preserve one nominal row". Every token keeps its own
37
+ protected 48 unit interaction line, so a populated row is taller than an
38
+ empty field by design. */
39
+ .ol8-multiselect__control {
40
+ flex-wrap: wrap;
41
+ align-content: center;
42
+ block-size: auto;
43
+ min-block-size: var(--_height);
44
+ gap: var(--ol8-component-token-field-gap);
45
+ padding-block: var(--ol8-component-token-field-padding-block);
46
+ }
47
+
48
+ /* The editable input keeps a usable measure before it gives up its row. */
49
+ .ol8-multiselect__input {
50
+ min-inline-size: var(--ol8-component-token-field-minimum-input);
51
+ }
52
+
53
+ /* A token awaiting a second Backspace. The first press only activates, so this
54
+ has to be visible: a silent activation would make the second press feel like
55
+ a single key deleting a value. */
56
+ .ol8-multiselect__token[data-ol8-active] {
57
+ --_surface: var(--ol8-color-selection-surface);
58
+ --_edge: var(--ol8-color-focus-outer);
59
+ }
60
+
61
+ /* A read only field's values are labelled content, so nothing inside invites a
62
+ click that would do nothing. */
63
+ .ol8-multiselect[data-ol8-readonly="true"] .ol8-multiselect__token,
64
+ .ol8-multiselect[data-ol8-availability="disabled"] .ol8-multiselect__token {
65
+ cursor: default;
66
+ }
67
+
68
+ /* Announcements only. Removing a value says so, with the remaining count. */
69
+ .ol8-multiselect__status {
70
+ position: absolute;
71
+ inline-size: 1px;
72
+ block-size: 1px;
73
+ overflow: hidden;
74
+ clip-path: inset(50%);
75
+ white-space: nowrap;
76
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Multi-select Field (ORGANISM) — several committed values in one field.
3
+ * Built from the canonical Text Field, Token and Selection Popup owners, as the
4
+ * Figma organism is. Outline and Filled are one component with an appearance
5
+ * axis, because the two Figma sets differ only by which field owner they use.
6
+ */
7
+ import type { Ol8TokenSize } from '../../molecules/token/token.js';
8
+
9
+ export type Ol8MultiSelectSize = Ol8TokenSize;
10
+ export type Ol8MultiSelectAppearance = 'outline' | 'filled';
11
+ export type Ol8MultiSelectMaterial = 'regular' | 'gem';
12
+ /** Constrained resolves from a known set; suggestive may also author values. */
13
+ export type Ol8ValuePolicy = 'constrained' | 'suggestive';
14
+
15
+ export declare const OL8_MULTI_SELECT_SIZES: readonly Ol8MultiSelectSize[];
16
+ export declare const OL8_MULTI_SELECT_APPEARANCES: readonly Ol8MultiSelectAppearance[];
17
+ export declare const OL8_MULTI_SELECT_MATERIALS: readonly Ol8MultiSelectMaterial[];
18
+ export declare const OL8_VALUE_POLICIES: readonly Ol8ValuePolicy[];
19
+
20
+ export interface Ol8MultiSelectOption {
21
+ value: string;
22
+ label: string;
23
+ description?: string;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ export interface Ol8MultiSelectFieldOptions {
28
+ label: string;
29
+ id?: string;
30
+ name?: string;
31
+ values?: Array<string | { value: string; label: string }>;
32
+ options?: Ol8MultiSelectOption[];
33
+ valuePolicy?: Ol8ValuePolicy;
34
+ size?: Ol8MultiSelectSize;
35
+ appearance?: Ol8MultiSelectAppearance;
36
+ material?: Ol8MultiSelectMaterial;
37
+ /** Figma's Expanded axis: whether the option collection is showing. */
38
+ expanded?: boolean;
39
+ inputValue?: string;
40
+ placeholder?: string;
41
+ maximumValues?: number;
42
+ allowDuplicates?: boolean;
43
+ showLabel?: boolean;
44
+ required?: boolean;
45
+ disabled?: boolean;
46
+ /** Only when tokens and input become noninteractive labelled content. */
47
+ readOnly?: boolean;
48
+ invalid?: boolean;
49
+ instruction?: string;
50
+ message?: string;
51
+ messageTone?: 'critical' | 'caution' | 'positive' | 'informative';
52
+ status?: 'none' | 'loading' | 'empty' | 'error';
53
+ statusText?: string;
54
+ className?: string;
55
+ }
56
+
57
+ export declare function renderMultiSelectField(options: Ol8MultiSelectFieldOptions): string;
58
+
59
+ /**
60
+ * Binds the keyboard model: Tab enters at the input, arrows walk the committed
61
+ * values, and removal takes two presses. Removal dispatches a cancelable
62
+ * `ol8:removevalue` rather than mutating the DOM.
63
+ */
64
+ export declare function hydrateMultiSelectFields(root?: ParentNode): number;