@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,317 @@
1
+ /**
2
+ * Oneli8 · Multi-select Field (ORGANISM)
3
+ *
4
+ * Figma: Select and Combobox
5
+ * Organism / Multi-select Field / Outline · Soft Hexagon 904:2507 (16)
6
+ * Organism / Multi-select Field / Filled · Soft Hexagon 978:4472 (16)
7
+ *
8
+ * From the Figma description:
9
+ * "Outline multi-select organism built from canonical Text Field, Token,
10
+ * Input Text, and Suggestion Popup owners. Size propagates through all
11
+ * children." The Filled set adds: "Same atomic composition and size
12
+ * behavior as Outline; only the canonical field appearance owner changes."
13
+ *
14
+ * That last sentence is why this is ONE component with an appearance axis
15
+ * rather than two. The two Figma sets differ by which field owner they
16
+ * instantiate, and in code that owner is the same stylesheet reading one
17
+ * attribute.
18
+ *
19
+ * WHAT FIGMA DRAWS AND WHAT IT DOES NOT
20
+ * In Figma the tokens are absolutely positioned over the field shell, because
21
+ * a design file draws a picture of a filled field rather than a layout that
22
+ * reflows. The real layout comes from the contract: tokens and the editable
23
+ * input share one wrapping row, "the field starts at its approved Text Field
24
+ * minimum height and grows in 6px or 12px-aligned increments as tokens wrap",
25
+ * and "it never compresses tokens to preserve one nominal row".
26
+ *
27
+ * WHY THE TOKENS ARE NOT A LIST
28
+ * The committed values and the input have to wrap as one run, so they must be
29
+ * siblings in one flex container. Wrapping a <ul> around the tokens would make
30
+ * the whole collection a single flex item that cannot share rows with the
31
+ * input. The order is still truthful, every token carries its own labelled
32
+ * Remove button, and changes are announced through the live region.
33
+ */
34
+ import { renderToken } from '../../molecules/token/token.js';
35
+ import { renderSelectionPopup } from '../selection-popup/selection-popup.js';
36
+
37
+ export const OL8_MULTI_SELECT_SIZES = ['compact', 'standard', 'comfortable', 'large'];
38
+ export const OL8_MULTI_SELECT_APPEARANCES = ['outline', 'filled'];
39
+ export const OL8_MULTI_SELECT_MATERIALS = ['regular', 'gem'];
40
+ /** Constrained resolves from a known set; suggestive may also author values. */
41
+ export const OL8_VALUE_POLICIES = ['constrained', 'suggestive'];
42
+
43
+ /**
44
+ * A committed value may be given as a bare string. In a constrained field that
45
+ * string is an option's value, so the token must show that option's label: a
46
+ * field holding "ui" reads "Interface", never "ui". Only a value with no
47
+ * matching option falls back to showing itself, which is what a suggestive
48
+ * field's authored entries are.
49
+ */
50
+ const toEntry = (v, options) => {
51
+ if (typeof v !== 'string') return v;
52
+ const match = options.find(o => o.value === v);
53
+ return match ? { value: match.value, label: match.label } : { value: v, label: v };
54
+ };
55
+
56
+ /**
57
+ * @param {{
58
+ * label:string, id?:string, name?:string,
59
+ * values?:Array<string|{value:string,label:string}>,
60
+ * options?:Array<{value:string,label:string,description?:string,disabled?:boolean}>,
61
+ * valuePolicy?:string, size?:string, appearance?:string, material?:string,
62
+ * expanded?:boolean, inputValue?:string, placeholder?:string,
63
+ * maximumValues?:number, allowDuplicates?:boolean,
64
+ * showLabel?:boolean, required?:boolean, disabled?:boolean, readOnly?:boolean,
65
+ * invalid?:boolean, instruction?:string, message?:string, messageTone?:string,
66
+ * status?:string, statusText?:string, className?:string,
67
+ * }} options
68
+ */
69
+ export function renderMultiSelectField(options = {}) {
70
+ const {
71
+ label, id, name,
72
+ values = [], options: items = [],
73
+ valuePolicy = 'constrained', size = 'comfortable',
74
+ appearance = 'outline', material = 'regular',
75
+ expanded = false, inputValue = '', placeholder,
76
+ maximumValues, allowDuplicates = false,
77
+ showLabel = true, required = false, disabled = false, readOnly = false,
78
+ invalid = false, instruction, message, messageTone = 'critical',
79
+ status = 'none', statusText, className,
80
+ } = options;
81
+
82
+ if (!label || !String(label).trim()) throw new Error('[ol8] a Multi-select Field requires a label');
83
+ if (!OL8_MULTI_SELECT_SIZES.includes(size)) throw new Error(`[ol8] unknown size "${size}"`);
84
+ if (!OL8_MULTI_SELECT_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown appearance "${appearance}"`);
85
+ if (!OL8_MULTI_SELECT_MATERIALS.includes(material)) throw new Error(`[ol8] unknown material "${material}"`);
86
+ if (!OL8_VALUE_POLICIES.includes(valuePolicy)) throw new Error(`[ol8] unknown value policy "${valuePolicy}"`);
87
+ if (readOnly && invalid) {
88
+ throw new Error('[ol8] a read only field cannot be invalid, because nobody can act on the message');
89
+ }
90
+
91
+ const committed = values.map(v => toEntry(v, items));
92
+
93
+ // "Constrained mode cannot create a non-option token."
94
+ if (valuePolicy === 'constrained' && items.length) {
95
+ const known = new Set(items.map(o => o.value));
96
+ const stray = committed.find(v => !known.has(v.value));
97
+ if (stray) {
98
+ throw new Error(`[ol8] "${stray.value}" is not one of the options, and a constrained field may not hold an authored value`);
99
+ }
100
+ }
101
+ // "A duplicate is prevented or surfaced according to policy; it is never
102
+ // added invisibly."
103
+ if (!allowDuplicates) {
104
+ const seen = new Set();
105
+ for (const v of committed) {
106
+ if (seen.has(v.value)) throw new Error(`[ol8] "${v.value}" is committed twice and allowDuplicates is false`);
107
+ seen.add(v.value);
108
+ }
109
+ }
110
+ if (maximumValues !== undefined && committed.length > maximumValues) {
111
+ throw new Error(`[ol8] ${committed.length} values exceed the maximum of ${maximumValues}`);
112
+ }
113
+
114
+ const uid = id ?? `ol8-multiselect-${Math.random().toString(36).slice(2, 9)}`;
115
+ const popupId = `${uid}-popup`;
116
+ const statusId = `${uid}-status`;
117
+ const instructionId = instruction ? `${uid}-instruction` : null;
118
+ const messageId = message ? `${uid}-message` : null;
119
+ const full = maximumValues !== undefined && committed.length >= maximumValues;
120
+
121
+ // Tokens carry the field's size. A read only field's values are labelled
122
+ // content, so they lose the Remove path rather than keeping a dead button.
123
+ const tokens = committed.map((v, i) => renderToken(v.label, {
124
+ size,
125
+ selected: true,
126
+ mark: readOnly || disabled ? 'none' : 'remove',
127
+ removable: !(readOnly || disabled),
128
+ focusable: false,
129
+ id: `${uid}-token-${i}`,
130
+ className: 'ol8-multiselect__token',
131
+ })).join('');
132
+
133
+ const describedBy = [instructionId, messageId, statusId].filter(Boolean).join(' ');
134
+
135
+ const input =
136
+ `<input class="ol8-field__input ol8-multiselect__input" id="${uid}"` +
137
+ (name ? ` name="${escapeAttr(name)}"` : '') +
138
+ ` type="text" role="combobox" aria-expanded="${expanded}" aria-controls="${popupId}"` +
139
+ ` aria-autocomplete="list" aria-haspopup="listbox"` +
140
+ (showLabel ? '' : ` aria-label="${escapeAttr(label)}"`) +
141
+ ` value="${escapeAttr(inputValue)}"` +
142
+ (placeholder ? ` placeholder="${escapeAttr(placeholder)}"` : '') +
143
+ (required ? ' required' : '') + (disabled ? ' disabled' : '') + (readOnly ? ' readonly' : '') +
144
+ (invalid ? ' aria-invalid="true"' : '') +
145
+ (messageId ? ` aria-errormessage="${messageId}"` : '') +
146
+ (describedBy ? ` aria-describedby="${describedBy}"` : '') +
147
+ '>';
148
+
149
+ const control =
150
+ `<div class="ol8-field__control ol8-multiselect__control">${tokens}${input}</div>`;
151
+
152
+ // Figma's root is a vertical stack of the field composition and the popup,
153
+ // twelve apart. The field group keeps its own six unit internal stack, so the
154
+ // two gaps stay separate things rather than one compromise.
155
+ const rootAttrs = [
156
+ `class="ol8-multiselect${className ? ` ${className}` : ''}"`,
157
+ `data-ol8-expanded="${expanded}"`,
158
+ `data-ol8-value-policy="${valuePolicy}"`,
159
+ disabled ? 'data-ol8-availability="disabled"' : '',
160
+ readOnly ? 'data-ol8-readonly="true"' : '',
161
+ full ? 'data-ol8-full="true"' : '',
162
+ ].filter(Boolean).join(' ');
163
+
164
+ const fieldAttrs = [
165
+ 'class="ol8-field ol8-multiselect__field"',
166
+ `data-ol8-size="${size}"`,
167
+ `data-ol8-appearance="${appearance}"`,
168
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
169
+ invalid ? 'data-ol8-validity="invalid"' : '',
170
+ disabled ? 'data-ol8-availability="disabled"' : '',
171
+ ].filter(Boolean).join(' ');
172
+
173
+ return `<div ${rootAttrs}>` +
174
+ `<div ${fieldAttrs}>` +
175
+ (showLabel
176
+ ? `<div class="ol8-field__label-row">` +
177
+ `<label class="ol8-field__label" for="${uid}">${escapeText(label)}</label>` +
178
+ (required ? `<span class="ol8-field__requirement" aria-hidden="true">*</span>` : '') +
179
+ `</div>`
180
+ : '') +
181
+ `<div class="ol8-field__control-stack">${control}</div>` +
182
+ (instruction ? `<div class="ol8-field__instruction" id="${instructionId}">${escapeText(instruction)}</div>` : '') +
183
+ (message
184
+ ? `<div class="ol8-field__supporting"><span class="ol8-field__message" id="${messageId}"` +
185
+ ` data-ol8-tone="${escapeAttr(messageTone)}" role="${messageTone === 'critical' ? 'alert' : 'status'}">` +
186
+ `${escapeText(message)}</span></div>`
187
+ : '') +
188
+ `</div>` +
189
+ (expanded
190
+ ? renderSelectionPopup({
191
+ id: popupId, label, options: items, multiple: true,
192
+ selected: committed.map(v => v.value),
193
+ width: 'anchor', size: size === 'large' ? 'large' : 'standard',
194
+ status, statusText, className: 'ol8-multiselect__popup',
195
+ })
196
+ : `<div id="${popupId}" hidden></div>`) +
197
+ // Selection and removal are announced here, with the remaining count, so a
198
+ // screen reader hears the result rather than inferring it from the DOM.
199
+ `<span class="ol8-multiselect__status" id="${statusId}" role="status" aria-live="polite">` +
200
+ (full ? `Maximum of ${maximumValues} reached` : '') +
201
+ `</span>` +
202
+ `</div>`;
203
+ }
204
+
205
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
206
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
207
+
208
+ /**
209
+ * Binds every field under `root`. Idempotent.
210
+ *
211
+ * This implements the keyboard model the contract writes down, which is the
212
+ * part of a multi value field that markup cannot express:
213
+ *
214
+ * - Tab enters at the input. Tokens are not tab stops, so their Remove
215
+ * buttons are reached with arrows instead.
216
+ * - With the caret at logical start, Left walks back through the committed
217
+ * values and Right walks forward and returns to the input. This uses
218
+ * logical order rather than screen direction, so it is already correct
219
+ * in a right to left document.
220
+ * - Backspace on an empty query activates the preceding token; a second
221
+ * Backspace or Delete removes it. Two steps, so one stray key cannot
222
+ * silently drop a value.
223
+ * - Escape cancels an active token preview and never clears the field.
224
+ *
225
+ * Removal dispatches `ol8:removevalue` rather than mutating the DOM, because
226
+ * the values belong to the application. The event is cancelable: call
227
+ * preventDefault to keep the token.
228
+ */
229
+ export function hydrateMultiSelectFields(root = document) {
230
+ const fields = [...root.querySelectorAll('.ol8-multiselect')];
231
+
232
+ for (const field of fields) {
233
+ if (field.dataset.ol8MultiselectBound === 'true') continue;
234
+ const input = field.querySelector(':scope .ol8-multiselect__input');
235
+ if (!input) continue;
236
+
237
+ const tokens = () => [...field.querySelectorAll(':scope .ol8-multiselect__token')];
238
+ const atStart = () => input.selectionStart === 0 && input.selectionEnd === 0;
239
+
240
+ const activate = (token) => {
241
+ for (const other of tokens()) delete other.dataset.ol8Active;
242
+ if (!token) return;
243
+ token.dataset.ol8Active = 'true';
244
+ const button = token.querySelector('.ol8-token__remove');
245
+ if (button) button.focus();
246
+ };
247
+ const clearActive = () => {
248
+ for (const token of tokens()) delete token.dataset.ol8Active;
249
+ };
250
+ const remove = (token) => {
251
+ const button = token.querySelector('.ol8-token__remove');
252
+ const label = token.querySelector('.ol8-chip__label');
253
+ const detail = { value: label ? label.textContent : null, token };
254
+ const proceed = field.dispatchEvent(
255
+ new CustomEvent('ol8:removevalue', { detail, bubbles: true, cancelable: true }));
256
+ if (!proceed) return;
257
+ const list = tokens();
258
+ const next = list[list.indexOf(token) + 1] ?? null;
259
+ announce(field, `${detail.value} removed, ${list.length - 1} remaining`);
260
+ if (next) activate(next); else input.focus();
261
+ };
262
+
263
+ input.addEventListener('keydown', (event) => {
264
+ if (event.key === 'Backspace' && input.value === '' && atStart()) {
265
+ const list = tokens();
266
+ if (!list.length) return;
267
+ event.preventDefault();
268
+ activate(list[list.length - 1]); // first press activates only
269
+ return;
270
+ }
271
+ if (event.key === 'ArrowLeft' && atStart()) {
272
+ const list = tokens();
273
+ if (!list.length) return;
274
+ event.preventDefault();
275
+ activate(list[list.length - 1]);
276
+ }
277
+ });
278
+
279
+ field.addEventListener('keydown', (event) => {
280
+ const token = event.target.closest && event.target.closest('.ol8-multiselect__token');
281
+ if (!token) return;
282
+ const list = tokens();
283
+ const index = list.indexOf(token);
284
+ if (event.key === 'ArrowLeft') {
285
+ event.preventDefault();
286
+ activate(list[Math.max(0, index - 1)]);
287
+ } else if (event.key === 'ArrowRight') {
288
+ event.preventDefault();
289
+ if (index === list.length - 1) { clearActive(); input.focus(); }
290
+ else activate(list[index + 1]);
291
+ } else if (event.key === 'Backspace' || event.key === 'Delete') {
292
+ event.preventDefault();
293
+ remove(token); // second press removes
294
+ } else if (event.key === 'Escape') {
295
+ // Escape cancels the preview. It never clears committed values.
296
+ event.preventDefault();
297
+ clearActive();
298
+ input.focus();
299
+ }
300
+ });
301
+
302
+ field.addEventListener('click', (event) => {
303
+ const button = event.target.closest && event.target.closest('.ol8-token__remove');
304
+ if (!button) return;
305
+ const token = button.closest('.ol8-multiselect__token');
306
+ if (token) remove(token);
307
+ });
308
+
309
+ field.dataset.ol8MultiselectBound = 'true';
310
+ }
311
+ return fields.length;
312
+ }
313
+
314
+ function announce(field, words) {
315
+ const region = field.querySelector(':scope > .ol8-multiselect__status');
316
+ if (region) region.textContent = words;
317
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared roving focus for the navigation organisms.
3
+ *
4
+ * From the navigation contract: "Arrow keys move through enabled Tabs; Home and
5
+ * End reach the boundaries", "Disabled choices are skipped by roving focus",
6
+ * and "mirrors horizontal Arrow keys in RTL". One implementation serves Tabs
7
+ * and Segmented Control so the three organisms cannot drift apart.
8
+ */
9
+
10
+ /** Which key means forward, given the orientation and the reading direction. */
11
+ export function navigationStep(key, orientation, rtl) {
12
+ const forwardInline = rtl ? 'ArrowLeft' : 'ArrowRight';
13
+ const backwardInline = rtl ? 'ArrowRight' : 'ArrowLeft';
14
+ if (orientation === 'vertical') {
15
+ if (key === 'ArrowDown') return 1;
16
+ if (key === 'ArrowUp') return -1;
17
+ } else {
18
+ if (key === forwardInline) return 1;
19
+ if (key === backwardInline) return -1;
20
+ }
21
+ return 0;
22
+ }
23
+
24
+ /** The next enabled index, wrapping, skipping anything disabled. */
25
+ export function nextEnabled(items, from, step) {
26
+ const n = items.length;
27
+ if (n === 0) return -1;
28
+ for (let hop = 1; hop <= n; hop += 1) {
29
+ const i = (from + step * hop + n * hop) % n;
30
+ if (!items[i].disabled) return i;
31
+ }
32
+ return from;
33
+ }
34
+
35
+ /** The first or last enabled index. */
36
+ export function edgeEnabled(items, edge) {
37
+ const order = edge === 'end' ? [...items.keys()].reverse() : [...items.keys()];
38
+ for (const i of order) if (!items[i].disabled) return i;
39
+ return -1;
40
+ }
41
+
42
+ /**
43
+ * Resolves a key press into the index that should now hold focus, or -1 when
44
+ * the key is not ours to handle.
45
+ */
46
+ export function resolveNavigationKey(event, items, activeIndex, { orientation = 'horizontal', rtl = false } = {}) {
47
+ if (event.key === 'Home') return edgeEnabled(items, 'start');
48
+ if (event.key === 'End') return edgeEnabled(items, 'end');
49
+ const step = navigationStep(event.key, orientation, rtl);
50
+ if (step === 0) return -1;
51
+ return nextEnabled(items, activeIndex, step);
52
+ }
53
+
54
+ /** True when the element sits in a right to left context. */
55
+ export function isRtl(element) {
56
+ if (typeof window === 'undefined' || !element) return false;
57
+ return window.getComputedStyle(element).direction === 'rtl';
58
+ }
@@ -0,0 +1 @@
1
+ export * from './segmented-control.js';
@@ -0,0 +1 @@
1
+ export { renderSegmentedControl, hydrateSegmentedControls, OL8_SEGMENT_BEHAVIORS, OL8_SEGMENT_PRESENTATIONS } from './segmented-control.js';
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Oneli8 · Segmented Control (ORGANISM)
3
+ * Figma: Navigation / Segmented Control 698:4792 — six presentations.
4
+ *
5
+ * "Presentation never changes behavior." Every presentation below is a
6
+ * treatment of the same Segment molecule, and none of them touches which
7
+ * behaviour the consumer declared.
8
+ *
9
+ * Read from Figma at Standard, Regular:
10
+ * Inset Fill sunken track, Border Default at the standard boundary,
11
+ * segments inset by the track inset, selected takes the
12
+ * selection surface
13
+ * Line Indicator no track; an adaptive secondary rail with a separate
14
+ * indicator in the primary colour, inset like a tab
15
+ * Outlined Selection no track; each segment carries its own boundary, the
16
+ * selected one in the primary colour at the strong width,
17
+ * with a deliberate gap between segments
18
+ * Soft Pill raised track at full radius; segments at full radius
19
+ * Icon-only the Inset Fill track, with icon only segments
20
+ * Stacked Label no track; segments stack their icon over their label
21
+ */
22
+
23
+ .ol8-segmented {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ box-sizing: border-box;
27
+ gap: var(--ol8-component-tab-bar-gap);
28
+ }
29
+
30
+ .ol8-segmented > .ol8-nav-item { flex: 1 1 0; }
31
+
32
+ /* ---- Inset Fill and Icon-only share one track ------------------------- */
33
+ .ol8-segmented[data-ol8-presentation="inset-fill"],
34
+ .ol8-segmented[data-ol8-presentation="icon-only"] {
35
+ padding: var(--ol8-component-segmented-control-track-inset);
36
+ border: var(--ol8-border-width-standard) solid var(--ol8-color-border-default);
37
+ border-radius: var(--ol8-component-segmented-control-track-radius);
38
+ background: var(--ol8-color-surface-sunken);
39
+ }
40
+ .ol8-segmented[data-ol8-presentation="inset-fill"] > .ol8-nav-item,
41
+ .ol8-segmented[data-ol8-presentation="icon-only"] > .ol8-nav-item {
42
+ --_radius: var(--ol8-component-segmented-control-item-radius);
43
+ }
44
+
45
+ /* ---- Soft Pill -------------------------------------------------------- */
46
+ .ol8-segmented[data-ol8-presentation="soft-pill"] {
47
+ padding: var(--ol8-component-segmented-control-track-inset);
48
+ border-radius: var(--ol8-shape-radius-full);
49
+ background: var(--ol8-color-surface-raised);
50
+ }
51
+ .ol8-segmented[data-ol8-presentation="soft-pill"] > .ol8-nav-item {
52
+ --_radius: var(--ol8-shape-radius-full);
53
+ }
54
+
55
+ /* ---- Line Indicator --------------------------------------------------- */
56
+ .ol8-segmented[data-ol8-presentation="line-indicator"] {
57
+ position: relative;
58
+ gap: 0;
59
+ }
60
+ .ol8-segmented[data-ol8-presentation="line-indicator"]::after {
61
+ content: "";
62
+ position: absolute;
63
+ inset-block-end: 0;
64
+ inset-inline: 0;
65
+ block-size: var(--ol8-component-tab-item-rail);
66
+ background: var(--ol8-color-text-secondary);
67
+ }
68
+ /* The active segment raises the line itself, so the indicator travels with the
69
+ selection rather than being positioned by script. */
70
+ .ol8-segmented[data-ol8-presentation="line-indicator"] > .ol8-nav-item { --_radius: 0; }
71
+ .ol8-segmented[data-ol8-presentation="line-indicator"] > .ol8-nav-item::after {
72
+ content: "";
73
+ position: absolute;
74
+ z-index: 1;
75
+ inset-block-end: 0;
76
+ inset-inline: var(--ol8-component-tab-item-indicator-inset);
77
+ block-size: var(--ol8-component-segmented-control-line);
78
+ border-radius: var(--ol8-shape-radius-full);
79
+ background: transparent;
80
+ }
81
+ .ol8-segmented[data-ol8-presentation="line-indicator"] > .ol8-nav-item[aria-pressed="true"] {
82
+ background: transparent;
83
+ color: var(--ol8-color-actionprimary-default);
84
+ }
85
+ .ol8-segmented[data-ol8-presentation="line-indicator"] > .ol8-nav-item[aria-pressed="true"]::after {
86
+ background: var(--ol8-color-actionprimary-default);
87
+ }
88
+
89
+ /* ---- Outlined Selection ----------------------------------------------- */
90
+ .ol8-segmented[data-ol8-presentation="outlined-selection"] {
91
+ gap: var(--ol8-component-segmented-control-outlined-gap);
92
+ }
93
+ .ol8-segmented[data-ol8-presentation="outlined-selection"] > .ol8-nav-item {
94
+ --_radius: var(--ol8-component-segmented-control-item-radius);
95
+ border: var(--ol8-component-segmented-control-boundary) solid var(--ol8-color-border-default);
96
+ }
97
+ .ol8-segmented[data-ol8-presentation="outlined-selection"] > .ol8-nav-item[aria-pressed="true"] {
98
+ border-width: var(--ol8-border-width-standard);
99
+ border-color: var(--ol8-color-actionprimary-default);
100
+ background: transparent;
101
+ color: var(--ol8-color-actionprimary-default);
102
+ }
103
+
104
+ /* ---- Stacked Label ---------------------------------------------------- */
105
+ .ol8-segmented[data-ol8-presentation="stacked-label"] > .ol8-nav-item {
106
+ --_radius: var(--ol8-component-segmented-control-item-radius);
107
+ }
108
+
109
+ @media (forced-colors: active) {
110
+ .ol8-segmented[data-ol8-presentation="line-indicator"] > .ol8-nav-item[aria-pressed="true"]::after {
111
+ background: Highlight;
112
+ }
113
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Segmented Control (ORGANISM). Figma 698:4792. Behaviour is declared, never
3
+ * inferred, and presentation never changes it.
4
+ */
5
+ import type { Ol8NavigationSize } from '../tabs/tabs.js';
6
+
7
+ export type Ol8SegmentBehavior = 'single' | 'multi' | 'momentary';
8
+ export type Ol8SegmentPresentation =
9
+ | 'inset-fill' | 'line-indicator' | 'outlined-selection'
10
+ | 'soft-pill' | 'icon-only' | 'stacked-label';
11
+
12
+ export declare const OL8_SEGMENT_BEHAVIORS: readonly Ol8SegmentBehavior[];
13
+ export declare const OL8_SEGMENT_PRESENTATIONS: readonly Ol8SegmentPresentation[];
14
+
15
+ export interface Ol8SegmentItem {
16
+ id: string;
17
+ label?: string;
18
+ icon?: string;
19
+ badge?: string | number;
20
+ disabled?: boolean;
21
+ /** Required when the item shows an icon and no label. */
22
+ ariaLabel?: string;
23
+ }
24
+
25
+ export interface Ol8SegmentedControlOptions {
26
+ /** Required accessible name. */
27
+ label: string;
28
+ /** Exactly one: single is a radio group, multi is toggle buttons, momentary stores nothing. */
29
+ behavior: Ol8SegmentBehavior;
30
+ items: Ol8SegmentItem[];
31
+ /** One value for single, an array for multi, nothing for momentary. */
32
+ selected?: string | string[];
33
+ presentation?: Ol8SegmentPresentation;
34
+ size?: Ol8NavigationSize;
35
+ material?: 'regular' | 'gem';
36
+ className?: string;
37
+ }
38
+
39
+ /** @throws without a name or behaviour, or when the selection shape contradicts the behaviour. */
40
+ export declare function renderSegmentedControl(options: Ol8SegmentedControlOptions): string;
41
+
42
+ /** Wires selection and, for single selection, roving focus. Returns the number of groups wired. */
43
+ export declare function hydrateSegmentedControls(root?: ParentNode): number;