@oneli8/react 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 +34 -4
- package/ai-context.json +116 -1
- package/package.json +29 -8
- package/src/atoms/NavigationBadge.js +19 -0
- package/src/foundations/geometry.js +71 -0
- package/src/index.d.ts +214 -0
- package/src/index.js +14 -0
- package/src/molecules/ChoiceChip.js +101 -0
- package/src/molecules/FormMessage.js +33 -0
- package/src/molecules/Link.js +31 -0
- package/src/molecules/NavigationItem.js +55 -0
- package/src/molecules/Select.js +109 -0
- package/src/molecules/SelectionOption.js +4 -1
- package/src/molecules/TextField.js +174 -0
- package/src/molecules/Token.js +49 -0
- package/src/organisms/ChoicePicker.js +114 -0
- package/src/organisms/Combobox.js +122 -0
- package/src/organisms/MultiSelectField.js +138 -0
- package/src/organisms/SegmentedControl.js +114 -0
- package/src/organisms/SelectionPopup.js +73 -0
- package/src/organisms/TabBar.js +68 -0
- package/src/organisms/Tabs.js +123 -0
- package/src/styles/choice-chip.css +216 -0
- package/src/styles/choice-picker.css +100 -0
- package/src/styles/combobox.css +42 -0
- package/src/styles/form-message.css +48 -0
- package/src/styles/gem.css +52 -5
- package/src/styles/link.css +116 -0
- package/src/styles/multi-select-field.css +78 -0
- package/src/styles/navigation-badge.css +37 -0
- package/src/styles/navigation-item.css +155 -0
- package/src/styles/segmented-control.css +115 -0
- package/src/styles/select.css +33 -0
- package/src/styles/selection-indicator.css +13 -4
- package/src/styles/selection-popup.css +92 -0
- package/src/styles/tab-bar.css +47 -0
- package/src/styles/tabs.css +55 -0
- package/src/styles/text-field.css +268 -0
- package/src/styles/token.css +65 -0
- package/styles.css +18 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createElement, useId } from 'react';
|
|
2
|
+
import { Icon } from '../atoms/Icon.js';
|
|
3
|
+
import { ChoiceChip } from '../molecules/ChoiceChip.js';
|
|
4
|
+
import { OL8_PICKER_SIZES, OL8_PICKER_MATERIALS, OL8_COMMIT_BEHAVIORS,
|
|
5
|
+
OL8_PICKER_PRESENTATIONS, OL8_PICKER_MARKS } from '../foundations/geometry.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ORGANISM. Search over a wrapping matrix of choices. The inline composition is
|
|
9
|
+
* canonical: an accessible group holding a search field, real checkboxes
|
|
10
|
+
* wearing the Choice Chip appearance, and, in Apply mode, a commitment line.
|
|
11
|
+
*
|
|
12
|
+
* It is a group rather than a dialog pretending to be a listbox, because the
|
|
13
|
+
* surface holds search, many independent choices and actions.
|
|
14
|
+
*
|
|
15
|
+
* Figma draws "Enter to apply" as text. The maintainer decision requires it to
|
|
16
|
+
* be a real clickable and focusable action, because a keyboard instruction is
|
|
17
|
+
* not an affordance for a pointer, touch, or a screen reader.
|
|
18
|
+
*/
|
|
19
|
+
export function ChoicePicker({
|
|
20
|
+
label, id, name, options = [], commitBehavior = 'immediate',
|
|
21
|
+
presentation = 'inline', mark = 'check', size = 'comfortable', material = 'regular',
|
|
22
|
+
inputValue, placeholder, showLabel = false, selectedSummary, applyHint,
|
|
23
|
+
showClearAll = false, clearAllLabel = 'Clear all',
|
|
24
|
+
disabled = false, readOnly = false, status = 'none', statusText,
|
|
25
|
+
onApply, onCancel, onClearAll, onToggle, onInputValueChange,
|
|
26
|
+
className = '', ...rest
|
|
27
|
+
}) {
|
|
28
|
+
if (!label) throw new Error('[ol8] a Choice Picker requires a label for its search');
|
|
29
|
+
if (!OL8_PICKER_SIZES.includes(size)) throw new Error(`[ol8] unknown size "${size}"`);
|
|
30
|
+
if (!OL8_PICKER_MATERIALS.includes(material)) throw new Error(`[ol8] unknown material "${material}"`);
|
|
31
|
+
if (!OL8_COMMIT_BEHAVIORS.includes(commitBehavior)) throw new Error(`[ol8] unknown commit behavior "${commitBehavior}"`);
|
|
32
|
+
if (!OL8_PICKER_PRESENTATIONS.includes(presentation)) throw new Error(`[ol8] unknown presentation "${presentation}"`);
|
|
33
|
+
if (!OL8_PICKER_MARKS.includes(mark)) throw new Error(`[ol8] unknown mark "${mark}"`);
|
|
34
|
+
if (commitBehavior === 'immediate' && applyHint !== undefined) {
|
|
35
|
+
throw new Error('[ol8] immediate commitment applies every toggle at once, so an apply action would be a lie');
|
|
36
|
+
}
|
|
37
|
+
if (status !== 'none' && !statusText) {
|
|
38
|
+
throw new Error('[ol8] a picker status needs words. A spinner alone tells a screen reader nothing.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const auto = useId();
|
|
42
|
+
const uid = id ?? `ol8-picker-${auto}`;
|
|
43
|
+
const labelId = `${uid}-label`;
|
|
44
|
+
const statusId = `${uid}-status`;
|
|
45
|
+
const selected = options.filter(o => o.selected);
|
|
46
|
+
const hint = applyHint ?? 'Enter to apply';
|
|
47
|
+
const locked = disabled || readOnly;
|
|
48
|
+
|
|
49
|
+
// A composing IME uses Enter to choose a candidate; committing there would
|
|
50
|
+
// apply a selection the person was still typing.
|
|
51
|
+
const onKeyDown = commitBehavior !== 'apply' ? undefined : (event) => {
|
|
52
|
+
if (event.nativeEvent && event.nativeEvent.isComposing) return;
|
|
53
|
+
if (event.key === 'Enter') { event.preventDefault(); onApply && onApply(); }
|
|
54
|
+
else if (event.key === 'Escape') { event.preventDefault(); onCancel && onCancel(); }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return createElement('div', {
|
|
58
|
+
...rest, onKeyDown,
|
|
59
|
+
className: `ol8-picker${className ? ` ${className}` : ''}`,
|
|
60
|
+
role: 'group', 'aria-labelledby': labelId,
|
|
61
|
+
'data-ol8-size': size, 'data-ol8-commitment': commitBehavior,
|
|
62
|
+
'data-ol8-presentation': presentation, 'data-ol8-mark': mark,
|
|
63
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
64
|
+
...(disabled ? { 'data-ol8-availability': 'disabled' } : {}),
|
|
65
|
+
...(readOnly ? { 'data-ol8-readonly': 'true' } : {}),
|
|
66
|
+
}, [
|
|
67
|
+
createElement('div', {
|
|
68
|
+
key: 'search', className: 'ol8-field ol8-picker__field',
|
|
69
|
+
'data-ol8-size': size, 'data-ol8-appearance': 'outline',
|
|
70
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
71
|
+
}, [
|
|
72
|
+
createElement('div', {
|
|
73
|
+
key: 'lr',
|
|
74
|
+
className: `ol8-field__label-row${showLabel ? '' : ' ol8-picker__label-row--hidden'}`,
|
|
75
|
+
}, createElement('label', { className: 'ol8-field__label', id: labelId, htmlFor: uid }, label)),
|
|
76
|
+
createElement('div', { key: 'cs', className: 'ol8-field__control-stack' },
|
|
77
|
+
createElement('div', { className: 'ol8-field__control' }, [
|
|
78
|
+
createElement(Icon, { key: 'i', name: 'search', size: 18, className: 'ol8-field__leading-icon' }),
|
|
79
|
+
createElement('input', {
|
|
80
|
+
key: 'input', className: 'ol8-field__input ol8-picker__input', id: uid, type: 'search',
|
|
81
|
+
...(inputValue !== undefined
|
|
82
|
+
? { value: inputValue, onChange: (e) => onInputValueChange && onInputValueChange(e.target.value) }
|
|
83
|
+
: {}),
|
|
84
|
+
placeholder, disabled, readOnly, 'aria-describedby': statusId,
|
|
85
|
+
}),
|
|
86
|
+
])),
|
|
87
|
+
]),
|
|
88
|
+
createElement('div', { key: 'matrix', className: 'ol8-picker__matrix' },
|
|
89
|
+
options.map((o, i) => createElement(ChoiceChip, {
|
|
90
|
+
key: o.value, size, selected: !!o.selected,
|
|
91
|
+
showMark: mark === 'check' && !!o.selected,
|
|
92
|
+
disabled: locked || o.disabled,
|
|
93
|
+
name: name ? `${name}[]` : undefined, value: o.value,
|
|
94
|
+
id: `${uid}-option-${i}`, className: 'ol8-picker__chip',
|
|
95
|
+
onChange: onToggle ? () => onToggle(o) : undefined,
|
|
96
|
+
}, o.label))),
|
|
97
|
+
commitBehavior === 'apply'
|
|
98
|
+
? createElement('div', { key: 'commit', className: 'ol8-picker__commitment' }, [
|
|
99
|
+
createElement('span', { key: 's', className: 'ol8-picker__summary' },
|
|
100
|
+
selectedSummary ?? `${selected.length} selected`),
|
|
101
|
+
showClearAll ? createElement('button', {
|
|
102
|
+
key: 'c', type: 'button', className: 'ol8-picker__clear', onClick: onClearAll, disabled: locked,
|
|
103
|
+
}, clearAllLabel) : null,
|
|
104
|
+
createElement('button', {
|
|
105
|
+
key: 'a', type: 'button', className: 'ol8-picker__apply', onClick: onApply, disabled: locked,
|
|
106
|
+
}, hint),
|
|
107
|
+
])
|
|
108
|
+
: null,
|
|
109
|
+
createElement('span', {
|
|
110
|
+
key: 'status', className: 'ol8-picker__status', id: statusId,
|
|
111
|
+
role: 'status', 'aria-live': 'polite',
|
|
112
|
+
}, status !== 'none' ? statusText : ''),
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createElement, useId } from 'react';
|
|
2
|
+
import { Icon } from '../atoms/Icon.js';
|
|
3
|
+
import { FormMessage } from '../molecules/FormMessage.js';
|
|
4
|
+
import { SelectionPopup } from './SelectionPopup.js';
|
|
5
|
+
import { OL8_COMBOBOX_SIZES, OL8_COMBOBOX_APPEARANCES, OL8_COMBOBOX_MATERIALS,
|
|
6
|
+
OL8_VALUE_POLICIES, OL8_AUTOCOMPLETE_MODES, OL8_FILTER_SOURCES } from '../foundations/geometry.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ORGANISM. Figma 883:2159: "Reuses approved Text Field, Selection Popup and
|
|
10
|
+
* governed icon atoms", and "Expanded remains Boolean" so the design does not
|
|
11
|
+
* explode into a variant per state.
|
|
12
|
+
*
|
|
13
|
+
* The contract is unusually specific about what code owns, and this markup is
|
|
14
|
+
* shaped by it:
|
|
15
|
+
*
|
|
16
|
+
* "Editable Combobox uses a native single-line text input as its editing
|
|
17
|
+
* surface" — so caret, selection, composition, dictation, undo and paste all
|
|
18
|
+
* keep working, because they are the browser's.
|
|
19
|
+
* "DOM focus remains on the Combobox and active option is communicated with
|
|
20
|
+
* aria-activedescendant. Popup descendants are excluded from the page Tab
|
|
21
|
+
* sequence."
|
|
22
|
+
* "Current value, active option and keyboard focus are distinct states."
|
|
23
|
+
*
|
|
24
|
+
* React owns none of the keyboard model here. Opening, the active option and
|
|
25
|
+
* committing are application state, so they arrive as props and leave as
|
|
26
|
+
* callbacks; a component that tried to own them would fight the consumer.
|
|
27
|
+
*/
|
|
28
|
+
export function Combobox({
|
|
29
|
+
label, options = [], id, name, inputValue, value,
|
|
30
|
+
size = 'comfortable', appearance = 'outline', material = 'regular',
|
|
31
|
+
open = false, active, showLabel = true,
|
|
32
|
+
required = false, disabled = false, invalid = false, readOnly = false,
|
|
33
|
+
placeholder, leadingIcon, clearable = false,
|
|
34
|
+
instruction, message, messageTone = 'critical',
|
|
35
|
+
valuePolicy = 'constrained', autocomplete = 'list-manual', filter = 'local',
|
|
36
|
+
loading = false, emptyText, retrievalError,
|
|
37
|
+
popupWidth = 'anchor', onInputValueChange, onClear, className = '', ...rest
|
|
38
|
+
}) {
|
|
39
|
+
if (typeof label !== 'string' || label === '') {
|
|
40
|
+
throw new Error('[ol8] Combobox requires a label, and its accessible name stays distinct from its value.');
|
|
41
|
+
}
|
|
42
|
+
if (!OL8_COMBOBOX_SIZES.includes(size)) throw new Error(`[ol8] unknown combobox size "${size}"`);
|
|
43
|
+
if (!OL8_COMBOBOX_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown combobox appearance "${appearance}"`);
|
|
44
|
+
if (!OL8_COMBOBOX_MATERIALS.includes(material)) throw new Error(`[ol8] unknown combobox material "${material}"`);
|
|
45
|
+
if (!OL8_VALUE_POLICIES.includes(valuePolicy)) throw new Error(`[ol8] unknown value policy "${valuePolicy}"`);
|
|
46
|
+
if (!OL8_AUTOCOMPLETE_MODES.includes(autocomplete)) throw new Error(`[ol8] unknown autocomplete mode "${autocomplete}"`);
|
|
47
|
+
if (!OL8_FILTER_SOURCES.includes(filter)) throw new Error(`[ol8] unknown filter source "${filter}"`);
|
|
48
|
+
if (clearable && required) {
|
|
49
|
+
throw new Error('[ol8] a required Combobox is not clearable, because clearing it would leave a value the form cannot accept.');
|
|
50
|
+
}
|
|
51
|
+
if (readOnly && invalid) {
|
|
52
|
+
throw new Error('[ol8] a read only Combobox cannot be invalid, for the same reason a read only field cannot be.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const auto = useId();
|
|
56
|
+
const uid = id ?? `ol8-combobox-${auto}`;
|
|
57
|
+
const popupId = `${uid}-listbox`;
|
|
58
|
+
const instructionId = instruction ? `${uid}-instruction` : undefined;
|
|
59
|
+
const messageId = message ? `${uid}-message` : undefined;
|
|
60
|
+
const describedBy = [instructionId, messageId].filter(Boolean).join(' ') || undefined;
|
|
61
|
+
|
|
62
|
+
// Four distinct conditions, never collapsed into one "nothing here".
|
|
63
|
+
const status = loading ? 'loading'
|
|
64
|
+
: retrievalError ? 'error'
|
|
65
|
+
: (options.length === 0 && emptyText) ? 'empty'
|
|
66
|
+
: 'none';
|
|
67
|
+
const statusText = loading ? 'Loading' : retrievalError || emptyText;
|
|
68
|
+
|
|
69
|
+
return createElement('div', {
|
|
70
|
+
...rest,
|
|
71
|
+
className: `ol8-field ol8-combobox${className ? ` ${className}` : ''}`,
|
|
72
|
+
'data-ol8-size': size, 'data-ol8-appearance': appearance,
|
|
73
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
74
|
+
...(disabled ? { 'data-ol8-disabled': 'true' } : {}),
|
|
75
|
+
...(readOnly ? { 'data-ol8-readonly': 'true' } : {}),
|
|
76
|
+
...(invalid ? { 'data-ol8-invalid': 'true' } : {}),
|
|
77
|
+
'data-ol8-value-policy': valuePolicy,
|
|
78
|
+
'data-ol8-filter': filter,
|
|
79
|
+
...(value !== undefined ? { 'data-ol8-committed': value } : {}),
|
|
80
|
+
}, [
|
|
81
|
+
showLabel ? createElement('div', { key: 'lr', className: 'ol8-field__label-row' }, [
|
|
82
|
+
createElement('label', { key: 'l', className: 'ol8-field__label', htmlFor: uid }, label),
|
|
83
|
+
required ? createElement('span', { key: 'r', className: 'ol8-field__requirement', 'aria-hidden': 'true' }, '*') : null,
|
|
84
|
+
]) : null,
|
|
85
|
+
createElement('div', { key: 'cs', className: 'ol8-field__control-stack' },
|
|
86
|
+
createElement('div', { className: 'ol8-field__control' }, [
|
|
87
|
+
leadingIcon ? createElement(Icon, { key: 'li', name: leadingIcon, size: 18, className: 'ol8-field__leading-icon' }) : null,
|
|
88
|
+
createElement('input', {
|
|
89
|
+
key: 'input', className: 'ol8-field__input ol8-combobox__input', id: uid, type: 'text',
|
|
90
|
+
role: 'combobox', 'aria-expanded': String(open), 'aria-controls': popupId,
|
|
91
|
+
'aria-autocomplete': autocomplete === 'none' ? 'none' : 'list',
|
|
92
|
+
autoComplete: 'off',
|
|
93
|
+
...(active ? { 'aria-activedescendant': `${popupId}-option-${active}` } : {}),
|
|
94
|
+
name, placeholder, required, disabled, readOnly,
|
|
95
|
+
...(inputValue !== undefined
|
|
96
|
+
? { value: inputValue, onChange: (e) => onInputValueChange && onInputValueChange(e.target.value) }
|
|
97
|
+
: {}),
|
|
98
|
+
...(invalid ? { 'aria-invalid': 'true', ...(messageId ? { 'aria-errormessage': messageId } : {}) } : {}),
|
|
99
|
+
...(describedBy ? { 'aria-describedby': describedBy } : {}),
|
|
100
|
+
...(showLabel ? {} : { 'aria-label': label }),
|
|
101
|
+
}),
|
|
102
|
+
// "Clearing is an explicit named action", so it is a real button with a
|
|
103
|
+
// real name rather than an unlabelled cross. It is not a second tab
|
|
104
|
+
// stop: the input is the composite's only one.
|
|
105
|
+
clearable ? createElement('button', {
|
|
106
|
+
key: 'clear', type: 'button', className: 'ol8-combobox__clear', tabIndex: -1,
|
|
107
|
+
disabled, 'aria-label': `Clear ${label}`, onClick: onClear,
|
|
108
|
+
}, createElement(Icon, { name: 'clear', size: 18 })) : null,
|
|
109
|
+
createElement('span', { key: 'disc', className: 'ol8-combobox__disclosure', 'aria-hidden': 'true' },
|
|
110
|
+
createElement(Icon, { name: open ? 'chevron-up' : 'chevron-down', size: 18 })),
|
|
111
|
+
])),
|
|
112
|
+
instruction ? createElement('div', { key: 'i', className: 'ol8-field__instruction', id: instructionId }, instruction) : null,
|
|
113
|
+
message ? createElement('div', { key: 'm', className: 'ol8-field__supporting' },
|
|
114
|
+
createElement(FormMessage, { tone: messageTone, id: messageId }, message)) : null,
|
|
115
|
+
createElement('div', { key: 'popup', className: 'ol8-combobox__popup', ...(open ? {} : { hidden: true }) },
|
|
116
|
+
createElement(SelectionPopup, {
|
|
117
|
+
id: popupId, label, options, selected: value, active,
|
|
118
|
+
width: popupWidth, size: size === 'large' ? 'large' : 'standard',
|
|
119
|
+
status, statusText,
|
|
120
|
+
})),
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createElement, useId } from 'react';
|
|
2
|
+
import { Token } from '../molecules/Token.js';
|
|
3
|
+
import { SelectionPopup } from './SelectionPopup.js';
|
|
4
|
+
import { OL8_MULTI_SELECT_SIZES, OL8_MULTI_SELECT_APPEARANCES,
|
|
5
|
+
OL8_MULTI_SELECT_MATERIALS, OL8_VALUE_POLICIES } from '../foundations/geometry.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A committed value may be given as a bare string. In a constrained field that
|
|
9
|
+
* string is an option's value, so the token must show that option's label: a
|
|
10
|
+
* field holding "ui" reads "Interface", never "ui". Only a value with no
|
|
11
|
+
* matching option falls back to showing itself.
|
|
12
|
+
*/
|
|
13
|
+
const toEntry = (v, options) => {
|
|
14
|
+
if (typeof v !== 'string') return v;
|
|
15
|
+
const match = options.find(o => o.value === v);
|
|
16
|
+
return match ? { value: match.value, label: match.label } : { value: v, label: v };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ORGANISM. Several committed values in one field, built from the canonical
|
|
21
|
+
* Text Field, Token and Selection Popup owners exactly as the Figma organism
|
|
22
|
+
* is. Outline and Filled are one component with an appearance axis, because the
|
|
23
|
+
* two Figma sets differ only by which field owner they instantiate.
|
|
24
|
+
*
|
|
25
|
+
* The committed values and the editable input are siblings in one wrapping run,
|
|
26
|
+
* because they have to wrap together. The field grows rather than compressing
|
|
27
|
+
* tokens to hold one nominal row.
|
|
28
|
+
*/
|
|
29
|
+
export function MultiSelectField({
|
|
30
|
+
label, id, name, values = [], options = [],
|
|
31
|
+
valuePolicy = 'constrained', size = 'comfortable', appearance = 'outline',
|
|
32
|
+
material = 'regular', expanded = false, inputValue, placeholder,
|
|
33
|
+
maximumValues, allowDuplicates = false, showLabel = true, required = false,
|
|
34
|
+
disabled = false, readOnly = false, invalid = false, instruction, message,
|
|
35
|
+
messageTone = 'critical', status = 'none', statusText,
|
|
36
|
+
onRemoveValue, onInputValueChange, className = '', ...rest
|
|
37
|
+
}) {
|
|
38
|
+
if (!label) throw new Error('[ol8] a Multi-select Field requires a label');
|
|
39
|
+
if (!OL8_MULTI_SELECT_SIZES.includes(size)) throw new Error(`[ol8] unknown size "${size}"`);
|
|
40
|
+
if (!OL8_MULTI_SELECT_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown appearance "${appearance}"`);
|
|
41
|
+
if (!OL8_MULTI_SELECT_MATERIALS.includes(material)) throw new Error(`[ol8] unknown material "${material}"`);
|
|
42
|
+
if (!OL8_VALUE_POLICIES.includes(valuePolicy)) throw new Error(`[ol8] unknown value policy "${valuePolicy}"`);
|
|
43
|
+
if (readOnly && invalid) {
|
|
44
|
+
throw new Error('[ol8] a read only field cannot be invalid, because nobody can act on the message');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const committed = values.map(v => toEntry(v, options));
|
|
48
|
+
if (valuePolicy === 'constrained' && options.length) {
|
|
49
|
+
const known = new Set(options.map(o => o.value));
|
|
50
|
+
const stray = committed.find(v => !known.has(v.value));
|
|
51
|
+
if (stray) throw new Error(`[ol8] "${stray.value}" is not one of the options, and a constrained field may not hold an authored value`);
|
|
52
|
+
}
|
|
53
|
+
if (!allowDuplicates) {
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const v of committed) {
|
|
56
|
+
if (seen.has(v.value)) throw new Error(`[ol8] "${v.value}" is committed twice and allowDuplicates is false`);
|
|
57
|
+
seen.add(v.value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (maximumValues !== undefined && committed.length > maximumValues) {
|
|
61
|
+
throw new Error(`[ol8] ${committed.length} values exceed the maximum of ${maximumValues}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const auto = useId();
|
|
65
|
+
const uid = id ?? `ol8-multiselect-${auto}`;
|
|
66
|
+
const popupId = `${uid}-popup`;
|
|
67
|
+
const statusId = `${uid}-status`;
|
|
68
|
+
const instructionId = instruction ? `${uid}-instruction` : undefined;
|
|
69
|
+
const messageId = message ? `${uid}-message` : undefined;
|
|
70
|
+
const full = maximumValues !== undefined && committed.length >= maximumValues;
|
|
71
|
+
const locked = readOnly || disabled;
|
|
72
|
+
const describedBy = [instructionId, messageId, statusId].filter(Boolean).join(' ') || undefined;
|
|
73
|
+
|
|
74
|
+
return createElement('div', {
|
|
75
|
+
...rest,
|
|
76
|
+
className: `ol8-multiselect${className ? ` ${className}` : ''}`,
|
|
77
|
+
'data-ol8-expanded': String(expanded),
|
|
78
|
+
'data-ol8-value-policy': valuePolicy,
|
|
79
|
+
...(disabled ? { 'data-ol8-availability': 'disabled' } : {}),
|
|
80
|
+
...(readOnly ? { 'data-ol8-readonly': 'true' } : {}),
|
|
81
|
+
...(full ? { 'data-ol8-full': 'true' } : {}),
|
|
82
|
+
}, [
|
|
83
|
+
createElement('div', {
|
|
84
|
+
key: 'field',
|
|
85
|
+
className: 'ol8-field ol8-multiselect__field',
|
|
86
|
+
'data-ol8-size': size, 'data-ol8-appearance': appearance,
|
|
87
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
88
|
+
...(invalid ? { 'data-ol8-validity': 'invalid' } : {}),
|
|
89
|
+
...(disabled ? { 'data-ol8-availability': 'disabled' } : {}),
|
|
90
|
+
}, [
|
|
91
|
+
showLabel ? createElement('div', { key: 'lr', className: 'ol8-field__label-row' }, [
|
|
92
|
+
createElement('label', { key: 'l', className: 'ol8-field__label', htmlFor: uid }, label),
|
|
93
|
+
required ? createElement('span', { key: 'r', className: 'ol8-field__requirement', 'aria-hidden': 'true' }, '*') : null,
|
|
94
|
+
]) : null,
|
|
95
|
+
createElement('div', { key: 'cs', className: 'ol8-field__control-stack' },
|
|
96
|
+
createElement('div', { key: 'c', className: 'ol8-field__control ol8-multiselect__control' }, [
|
|
97
|
+
...committed.map((v, i) => createElement(Token, {
|
|
98
|
+
key: v.value, size, selected: true,
|
|
99
|
+
mark: locked ? 'none' : 'remove', removable: !locked, focusable: false,
|
|
100
|
+
className: 'ol8-multiselect__token',
|
|
101
|
+
id: `${uid}-token-${i}`,
|
|
102
|
+
onRemove: onRemoveValue ? () => onRemoveValue(v) : undefined,
|
|
103
|
+
}, v.label)),
|
|
104
|
+
createElement('input', {
|
|
105
|
+
key: 'input',
|
|
106
|
+
className: 'ol8-field__input ol8-multiselect__input', id: uid, name, type: 'text',
|
|
107
|
+
role: 'combobox', 'aria-expanded': String(expanded), 'aria-controls': popupId,
|
|
108
|
+
'aria-autocomplete': 'list', 'aria-haspopup': 'listbox',
|
|
109
|
+
...(showLabel ? {} : { 'aria-label': label }),
|
|
110
|
+
...(inputValue !== undefined
|
|
111
|
+
? { value: inputValue, onChange: (e) => onInputValueChange && onInputValueChange(e.target.value) }
|
|
112
|
+
: {}),
|
|
113
|
+
placeholder, required, disabled, readOnly,
|
|
114
|
+
...(invalid ? { 'aria-invalid': 'true', 'aria-errormessage': messageId } : {}),
|
|
115
|
+
...(describedBy ? { 'aria-describedby': describedBy } : {}),
|
|
116
|
+
}),
|
|
117
|
+
])),
|
|
118
|
+
instruction ? createElement('div', { key: 'i', className: 'ol8-field__instruction', id: instructionId }, instruction) : null,
|
|
119
|
+
message ? createElement('div', { key: 'm', className: 'ol8-field__supporting' },
|
|
120
|
+
createElement('span', {
|
|
121
|
+
className: 'ol8-field__message', id: messageId, 'data-ol8-tone': messageTone,
|
|
122
|
+
role: messageTone === 'critical' ? 'alert' : 'status',
|
|
123
|
+
}, message)) : null,
|
|
124
|
+
]),
|
|
125
|
+
expanded
|
|
126
|
+
? createElement(SelectionPopup, {
|
|
127
|
+
key: 'popup', id: popupId, label, options, multiple: true,
|
|
128
|
+
selected: committed.map(v => v.value),
|
|
129
|
+
width: 'anchor', size: size === 'large' ? 'large' : 'standard',
|
|
130
|
+
status, statusText, className: 'ol8-multiselect__popup',
|
|
131
|
+
})
|
|
132
|
+
: createElement('div', { key: 'popup', id: popupId, hidden: true }),
|
|
133
|
+
createElement('span', {
|
|
134
|
+
key: 'status', className: 'ol8-multiselect__status', id: statusId,
|
|
135
|
+
role: 'status', 'aria-live': 'polite',
|
|
136
|
+
}, full ? `Maximum of ${maximumValues} reached` : ''),
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createElement, forwardRef, useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { OL8_SEGMENT_BEHAVIORS, OL8_SEGMENT_PRESENTATIONS, OL8_NAVIGATION_SIZES } from '../foundations/geometry.js';
|
|
3
|
+
import { NavigationItem } from '../molecules/NavigationItem.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ORGANISM. Figma 698:4792.
|
|
7
|
+
*
|
|
8
|
+
* "Behavior is a consuming contract: single = Radio Group, multi = independent
|
|
9
|
+
* Toggle Buttons, momentary = grouped actions." The contract says to declare
|
|
10
|
+
* exactly one, so this component refuses to guess and checks that the shape of
|
|
11
|
+
* `value` matches the behaviour that was declared.
|
|
12
|
+
*
|
|
13
|
+
* "Presentation never changes behavior", so nothing below reads presentation
|
|
14
|
+
* except to decide how an item lays its own content out.
|
|
15
|
+
*/
|
|
16
|
+
export const SegmentedControl = forwardRef(function SegmentedControl({
|
|
17
|
+
label, behavior, items = [], value, defaultValue, onChange,
|
|
18
|
+
presentation = 'inset-fill', size = 'standard', material = 'regular',
|
|
19
|
+
className = '', ...rest
|
|
20
|
+
}, ref) {
|
|
21
|
+
if (typeof label !== 'string' || label === '') throw new Error('[ol8] a segmented control requires an accessible name.');
|
|
22
|
+
if (!OL8_SEGMENT_BEHAVIORS.includes(behavior)) {
|
|
23
|
+
throw new Error('[ol8] a segmented control must declare exactly one behavior: single, multi or momentary.');
|
|
24
|
+
}
|
|
25
|
+
if (!OL8_SEGMENT_PRESENTATIONS.includes(presentation)) throw new Error(`[ol8] unknown segmented control presentation "${presentation}"`);
|
|
26
|
+
if (!OL8_NAVIGATION_SIZES.includes(size)) throw new Error(`[ol8] unknown segmented control size "${size}"`);
|
|
27
|
+
if (!Array.isArray(items) || items.length === 0) throw new Error('[ol8] a segmented control needs at least one segment.');
|
|
28
|
+
if (behavior === 'multi' && value !== undefined && !Array.isArray(value)) {
|
|
29
|
+
throw new Error('[ol8] multi selection holds an array of values.');
|
|
30
|
+
}
|
|
31
|
+
if (behavior === 'single' && Array.isArray(value)) {
|
|
32
|
+
throw new Error('[ol8] single selection holds one value, not an array.');
|
|
33
|
+
}
|
|
34
|
+
if (behavior === 'momentary' && (value !== undefined || defaultValue !== undefined)) {
|
|
35
|
+
throw new Error('[ol8] a momentary control invokes an action and stores no selected state.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [internal, setInternal] = useState(defaultValue ?? (behavior === 'multi' ? [] : undefined));
|
|
39
|
+
const current = value ?? internal;
|
|
40
|
+
const groupRef = useRef(null);
|
|
41
|
+
|
|
42
|
+
const isSelected = (id) => {
|
|
43
|
+
if (behavior === 'momentary') return false;
|
|
44
|
+
if (behavior === 'multi') return Array.isArray(current) && current.includes(id);
|
|
45
|
+
return current === id;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const activate = useCallback((id) => {
|
|
49
|
+
if (behavior === 'momentary') { onChange?.(id); return; }
|
|
50
|
+
const next = behavior === 'multi'
|
|
51
|
+
? (Array.isArray(current) && current.includes(id)
|
|
52
|
+
? current.filter(v => v !== id)
|
|
53
|
+
: [...(current ?? []), id])
|
|
54
|
+
: id;
|
|
55
|
+
if (value === undefined) setInternal(next);
|
|
56
|
+
onChange?.(next);
|
|
57
|
+
}, [behavior, current, value, onChange]);
|
|
58
|
+
|
|
59
|
+
// Only single selection roves, because it is the only behaviour where the
|
|
60
|
+
// group holds one value. Multi and momentary are ordinary buttons in a row.
|
|
61
|
+
const onKeyDown = useCallback((event) => {
|
|
62
|
+
if (behavior !== 'single') return;
|
|
63
|
+
const buttons = [...(groupRef.current?.querySelectorAll('.ol8-nav-item--segment') ?? [])];
|
|
64
|
+
const at = buttons.indexOf(document.activeElement);
|
|
65
|
+
if (at === -1) return;
|
|
66
|
+
const rtl = getComputedStyle(groupRef.current).direction === 'rtl';
|
|
67
|
+
const forward = rtl ? 'ArrowLeft' : 'ArrowRight';
|
|
68
|
+
const back = rtl ? 'ArrowRight' : 'ArrowLeft';
|
|
69
|
+
let next = -1;
|
|
70
|
+
if (event.key === 'Home') next = items.findIndex(i => !i.disabled);
|
|
71
|
+
else if (event.key === 'End') next = items.map(i => !i.disabled).lastIndexOf(true);
|
|
72
|
+
else if (event.key === forward || event.key === back) {
|
|
73
|
+
const step = event.key === forward ? 1 : -1;
|
|
74
|
+
for (let hop = 1; hop <= items.length; hop += 1) {
|
|
75
|
+
const i = (at + step * hop + items.length * hop) % items.length;
|
|
76
|
+
if (!items[i].disabled) { next = i; break; }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (next === -1 || next === at) return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
buttons[next].focus();
|
|
82
|
+
activate(items[next].id);
|
|
83
|
+
}, [behavior, items, activate]);
|
|
84
|
+
|
|
85
|
+
const iconOnly = presentation === 'icon-only';
|
|
86
|
+
const layout = presentation === 'stacked-label' ? 'stacked' : 'inline';
|
|
87
|
+
const focusStop = behavior === 'single'
|
|
88
|
+
? (items.find(i => isSelected(i.id) && !i.disabled) ?? items.find(i => !i.disabled))
|
|
89
|
+
: null;
|
|
90
|
+
|
|
91
|
+
return createElement('div', {
|
|
92
|
+
...rest, ref: (node) => {
|
|
93
|
+
groupRef.current = node;
|
|
94
|
+
if (typeof ref === 'function') ref(node); else if (ref) ref.current = node;
|
|
95
|
+
},
|
|
96
|
+
className: `ol8-segmented${className ? ` ${className}` : ''}`,
|
|
97
|
+
role: behavior === 'single' ? 'radiogroup' : 'group',
|
|
98
|
+
'aria-label': label,
|
|
99
|
+
'data-ol8-presentation': presentation,
|
|
100
|
+
'data-ol8-behavior': behavior,
|
|
101
|
+
'data-ol8-size': size,
|
|
102
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
103
|
+
onKeyDown,
|
|
104
|
+
}, items.map(item => createElement(NavigationItem, {
|
|
105
|
+
key: item.id, kind: 'segment', size, layout,
|
|
106
|
+
label: iconOnly ? undefined : item.label,
|
|
107
|
+
icon: item.icon, badge: item.badge,
|
|
108
|
+
ariaLabel: item.ariaLabel ?? (iconOnly ? item.label : undefined),
|
|
109
|
+
disabled: item.disabled,
|
|
110
|
+
selected: isSelected(item.id),
|
|
111
|
+
...(focusStop ? { tabIndex: item.id === focusStop.id ? 0 : -1 } : {}),
|
|
112
|
+
onClick: () => activate(item.id),
|
|
113
|
+
})));
|
|
114
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import { Icon } from '../atoms/Icon.js';
|
|
3
|
+
import { SelectionOption } from '../molecules/SelectionOption.js';
|
|
4
|
+
import { OL8_POPUP_WIDTHS, OL8_POPUP_SIZES, OL8_POPUP_STATUSES } from '../foundations/geometry.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ORGANISM. The option collection a Select, Combobox or Multi-select Field
|
|
8
|
+
* points at. It owns no focus: a listbox moves a virtual cursor with
|
|
9
|
+
* aria-activedescendant while real focus stays on the input that owns it.
|
|
10
|
+
*
|
|
11
|
+
* A status is a region, never an option, because "No results" is not something
|
|
12
|
+
* a person can choose.
|
|
13
|
+
*/
|
|
14
|
+
export function SelectionPopup({
|
|
15
|
+
id, label, options = [], selected, active, width = 'anchor', size = 'standard',
|
|
16
|
+
status = 'none', statusText, multiple = false, className = '', ...rest
|
|
17
|
+
}) {
|
|
18
|
+
if (!id) throw new Error('[ol8] a selection popup needs an id, because the combobox that owns it points at it.');
|
|
19
|
+
if (typeof label !== 'string' || label === '') {
|
|
20
|
+
throw new Error('[ol8] a listbox requires an accessible name, distinct from the value it holds.');
|
|
21
|
+
}
|
|
22
|
+
if (!OL8_POPUP_WIDTHS.includes(width)) throw new Error(`[ol8] unknown popup width "${width}"`);
|
|
23
|
+
if (!OL8_POPUP_SIZES.includes(size)) throw new Error(`[ol8] unknown popup size "${size}"`);
|
|
24
|
+
if (!OL8_POPUP_STATUSES.includes(status)) throw new Error(`[ol8] unknown popup status "${status}"`);
|
|
25
|
+
if (status !== 'none' && !statusText) {
|
|
26
|
+
throw new Error('[ol8] a popup status needs words. A spinner alone tells a screen reader nothing.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isSelected = (value) => Array.isArray(selected) ? selected.includes(value) : selected === value;
|
|
30
|
+
|
|
31
|
+
// Grouped results keep their source order; grouping never reorders matches.
|
|
32
|
+
const groups = [];
|
|
33
|
+
for (const item of options) {
|
|
34
|
+
const name = item.group ?? null;
|
|
35
|
+
const last = groups[groups.length - 1];
|
|
36
|
+
if (last && last.name === name) last.items.push(item);
|
|
37
|
+
else groups.push({ name, items: [item] });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rail = groups.map((group, index) => {
|
|
41
|
+
const groupId = group.name ? `${id}-group-${index}` : null;
|
|
42
|
+
const rendered = group.items.map(item => createElement(SelectionOption, {
|
|
43
|
+
key: item.value, value: item.value, size,
|
|
44
|
+
selected: isSelected(item.value), active: item.value === active,
|
|
45
|
+
disabled: item.disabled, description: item.description,
|
|
46
|
+
leadingIcon: item.leadingIcon, id: `${id}-option-${item.value}`,
|
|
47
|
+
}, item.label));
|
|
48
|
+
if (!group.name) return rendered;
|
|
49
|
+
return createElement('div', { key: `g${index}`, role: 'group', 'aria-labelledby': groupId }, [
|
|
50
|
+
createElement('span', { key: 'l', className: 'ol8-popup__group-label', id: groupId }, group.name),
|
|
51
|
+
...rendered,
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return createElement('div', {
|
|
56
|
+
...rest,
|
|
57
|
+
className: `ol8-popup${className ? ` ${className}` : ''}`,
|
|
58
|
+
'data-ol8-width': width, 'data-ol8-size': size,
|
|
59
|
+
}, [
|
|
60
|
+
createElement('div', {
|
|
61
|
+
key: 'rail', className: 'ol8-popup__rail', role: 'listbox', id, 'aria-label': label,
|
|
62
|
+
...(multiple ? { 'aria-multiselectable': 'true' } : {}),
|
|
63
|
+
}, rail),
|
|
64
|
+
status === 'none' ? null : createElement('div', {
|
|
65
|
+
key: 'status', className: 'ol8-popup__status', 'data-ol8-status': status,
|
|
66
|
+
role: 'status', 'aria-live': 'polite',
|
|
67
|
+
}, [
|
|
68
|
+
status === 'loading' ? createElement(Icon, { key: 'i', name: 'loading', size: 18 }) : null,
|
|
69
|
+
status === 'error' ? createElement(Icon, { key: 'i', name: 'critical', size: 18 }) : null,
|
|
70
|
+
createElement('span', { key: 's' }, statusText),
|
|
71
|
+
]),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createElement, forwardRef, useCallback } from 'react';
|
|
2
|
+
import { OL8_TAB_BAR_PRESENTATIONS, OL8_NAVIGATION_SIZES } from '../foundations/geometry.js';
|
|
3
|
+
import { NavigationItem } from '../molecules/NavigationItem.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ORGANISM. Figma 701:460.
|
|
7
|
+
*
|
|
8
|
+
* "Production uses native destination links, requires a non empty navigation
|
|
9
|
+
* name, resolves exactly one current destination, and rejects disabled pseudo
|
|
10
|
+
* destinations."
|
|
11
|
+
*
|
|
12
|
+
* `onNavigate` lets a router take over without replacing link semantics: the
|
|
13
|
+
* destination stays a real link, so opening in a new tab, copying the address
|
|
14
|
+
* and middle clicking all keep working.
|
|
15
|
+
*/
|
|
16
|
+
export const TabBar = forwardRef(function TabBar({
|
|
17
|
+
label, items = [], current, onNavigate,
|
|
18
|
+
presentation = 'bottom', size = 'standard', material = 'regular',
|
|
19
|
+
className = '', ...rest
|
|
20
|
+
}, ref) {
|
|
21
|
+
if (typeof label !== 'string' || label === '') {
|
|
22
|
+
throw new Error('[ol8] a tab bar requires a navigation name, so a person can tell one landmark from another.');
|
|
23
|
+
}
|
|
24
|
+
if (!OL8_TAB_BAR_PRESENTATIONS.includes(presentation)) throw new Error(`[ol8] unknown tab bar presentation "${presentation}"`);
|
|
25
|
+
if (!OL8_NAVIGATION_SIZES.includes(size)) throw new Error(`[ol8] unknown tab bar size "${size}"`);
|
|
26
|
+
if (!Array.isArray(items) || items.length === 0) throw new Error('[ol8] a tab bar needs at least one destination.');
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
if (typeof item.href !== 'string' || item.href === '') {
|
|
30
|
+
throw new Error('[ol8] every destination needs an href. A tab bar navigates, so it uses real links.');
|
|
31
|
+
}
|
|
32
|
+
if (item.disabled) {
|
|
33
|
+
throw new Error('[ol8] a destination cannot be disabled. Remove the place rather than showing a door that does not open.');
|
|
34
|
+
}
|
|
35
|
+
if (seen.has(item.href)) throw new Error(`[ol8] two destinations share the href "${item.href}"`);
|
|
36
|
+
seen.add(item.href);
|
|
37
|
+
}
|
|
38
|
+
if (current !== undefined && !seen.has(current)) {
|
|
39
|
+
throw new Error(`[ol8] the current destination "${current}" is not one of the destinations listed.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handleClick = useCallback((href) => (event) => {
|
|
43
|
+
if (!onNavigate) return;
|
|
44
|
+
if (event.defaultPrevented || event.button !== 0) return;
|
|
45
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
onNavigate(href, event);
|
|
48
|
+
}, [onNavigate]);
|
|
49
|
+
|
|
50
|
+
// The spatial rail carries no labels, so a name has to come from somewhere.
|
|
51
|
+
const iconOnly = presentation === 'spatial-rail';
|
|
52
|
+
|
|
53
|
+
return createElement('nav', {
|
|
54
|
+
...rest, ref,
|
|
55
|
+
className: `ol8-tab-bar${className ? ` ${className}` : ''}`,
|
|
56
|
+
'aria-label': label,
|
|
57
|
+
'data-ol8-presentation': presentation,
|
|
58
|
+
'data-ol8-size': size,
|
|
59
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
60
|
+
}, items.map(item => createElement(NavigationItem, {
|
|
61
|
+
key: item.href, kind: 'destination', size,
|
|
62
|
+
label: iconOnly ? undefined : item.label,
|
|
63
|
+
icon: item.icon, badge: item.badge, href: item.href,
|
|
64
|
+
ariaLabel: item.ariaLabel ?? (iconOnly ? item.label : undefined),
|
|
65
|
+
selected: item.href === current,
|
|
66
|
+
onClick: handleClick(item.href),
|
|
67
|
+
})));
|
|
68
|
+
});
|