@oneli8/core 1.0.0-beta.4 → 1.0.0-beta.6
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.
- package/README.md +51 -13
- package/ai-context.json +168 -1
- package/package.json +18 -3
- package/src/atoms/index.d.ts +1 -0
- package/src/atoms/index.js +1 -0
- package/src/atoms/navigation-badge/index.d.ts +1 -0
- package/src/atoms/navigation-badge/index.js +1 -0
- package/src/atoms/navigation-badge/navigation-badge.css +35 -0
- package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
- package/src/atoms/navigation-badge/navigation-badge.js +21 -0
- package/src/atoms/selection-indicator/selection-indicator.css +13 -4
- package/src/materials/gem/gem.css +52 -5
- package/src/molecules/choice-chip/choice-chip.css +214 -0
- package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
- package/src/molecules/choice-chip/choice-chip.js +170 -0
- package/src/molecules/choice-chip/index.d.ts +2 -0
- package/src/molecules/choice-chip/index.js +10 -0
- package/src/molecules/form-message/form-message.css +46 -0
- package/src/molecules/form-message/form-message.d.ts +23 -0
- package/src/molecules/form-message/form-message.js +73 -0
- package/src/molecules/form-message/index.d.ts +1 -0
- package/src/molecules/form-message/index.js +1 -0
- package/src/molecules/index.d.ts +6 -0
- package/src/molecules/index.js +6 -0
- package/src/molecules/link/index.d.ts +1 -0
- package/src/molecules/link/index.js +1 -0
- package/src/molecules/link/link.css +114 -0
- package/src/molecules/link/link.d.ts +27 -0
- package/src/molecules/link/link.js +76 -0
- package/src/molecules/navigation-item/index.js +1 -0
- package/src/molecules/navigation-item/navigation-item.css +153 -0
- package/src/molecules/navigation-item/navigation-item.js +95 -0
- package/src/molecules/select/index.js +1 -0
- package/src/molecules/select/select.css +31 -0
- package/src/molecules/select/select.js +127 -0
- package/src/molecules/selection-option/selection-option.js +4 -4
- package/src/molecules/text-field/index.d.ts +1 -0
- package/src/molecules/text-field/index.js +5 -0
- package/src/molecules/text-field/text-field.css +266 -0
- package/src/molecules/text-field/text-field.d.ts +69 -0
- package/src/molecules/text-field/text-field.js +234 -0
- package/src/molecules/token/index.d.ts +2 -0
- package/src/molecules/token/index.js +1 -0
- package/src/molecules/token/token.css +63 -0
- package/src/molecules/token/token.d.ts +35 -0
- package/src/molecules/token/token.js +83 -0
- package/src/organisms/choice-picker/choice-picker.css +98 -0
- package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
- package/src/organisms/choice-picker/choice-picker.js +201 -0
- package/src/organisms/choice-picker/index.d.ts +1 -0
- package/src/organisms/choice-picker/index.js +1 -0
- package/src/organisms/combobox/combobox.css +40 -0
- package/src/organisms/combobox/combobox.js +309 -0
- package/src/organisms/combobox/index.js +5 -0
- package/src/organisms/index.d.ts +7 -0
- package/src/organisms/index.js +7 -0
- package/src/organisms/multi-select-field/index.d.ts +1 -0
- package/src/organisms/multi-select-field/index.js +1 -0
- package/src/organisms/multi-select-field/multi-select-field.css +76 -0
- package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
- package/src/organisms/multi-select-field/multi-select-field.js +317 -0
- package/src/organisms/navigation-keys.js +58 -0
- package/src/organisms/segmented-control/index.d.ts +1 -0
- package/src/organisms/segmented-control/index.js +1 -0
- package/src/organisms/segmented-control/segmented-control.css +113 -0
- package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
- package/src/organisms/segmented-control/segmented-control.js +163 -0
- package/src/organisms/selection-popup/index.js +1 -0
- package/src/organisms/selection-popup/selection-popup.css +90 -0
- package/src/organisms/selection-popup/selection-popup.js +100 -0
- package/src/organisms/tab-bar/index.d.ts +1 -0
- package/src/organisms/tab-bar/index.js +1 -0
- package/src/organisms/tab-bar/tab-bar.css +45 -0
- package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
- package/src/organisms/tab-bar/tab-bar.js +104 -0
- package/src/organisms/tabs/index.d.ts +1 -0
- package/src/organisms/tabs/index.js +1 -0
- package/src/organisms/tabs/tabs.css +53 -0
- package/src/organisms/tabs/tabs.d.ts +48 -0
- package/src/organisms/tabs/tabs.js +152 -0
- 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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
206
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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;
|