@oneli8/react 1.0.0-beta.5 → 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 +7 -3
- package/ai-context.json +116 -1
- package/package.json +2 -2
- 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,33 @@
|
|
|
1
|
+
import { createElement, forwardRef } from 'react';
|
|
2
|
+
import { OL8_FORM_MESSAGE_TONES, OL8_FORM_MESSAGE_ICONS } from '../foundations/geometry.js';
|
|
3
|
+
import { Icon } from '../atoms/Icon.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MOLECULE. Figma 177:13: "Tone maps to semantic message tone; Pending is
|
|
7
|
+
* resolved through validationStatus rather than treated as a fifth error tone.
|
|
8
|
+
* Runtime role, status announcement and relationships live in code."
|
|
9
|
+
*
|
|
10
|
+
* So the role and the live region are decided here, from the tone: critical is
|
|
11
|
+
* the only tone that interrupts, because it is the only one that stops a person
|
|
12
|
+
* from finishing.
|
|
13
|
+
*/
|
|
14
|
+
export const FormMessage = forwardRef(function FormMessage({
|
|
15
|
+
children, tone = 'critical', icon = true, className = '', ...rest
|
|
16
|
+
}, ref) {
|
|
17
|
+
if (!OL8_FORM_MESSAGE_TONES.includes(tone)) {
|
|
18
|
+
throw new Error(`[ol8] unknown form message tone "${tone}"`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return createElement('div', {
|
|
22
|
+
...rest, ref,
|
|
23
|
+
className: `ol8-form-message${className ? ` ${className}` : ''}`,
|
|
24
|
+
'data-ol8-tone': tone,
|
|
25
|
+
role: tone === 'critical' ? 'alert' : 'status',
|
|
26
|
+
'aria-live': tone === 'critical' ? 'assertive' : 'polite',
|
|
27
|
+
},
|
|
28
|
+
icon
|
|
29
|
+
? createElement(Icon, { key: 'icon', name: OL8_FORM_MESSAGE_ICONS[tone], size: 18, className: 'ol8-form-message__icon' })
|
|
30
|
+
: null,
|
|
31
|
+
createElement('span', { key: 'text', className: 'ol8-form-message__text' }, children),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createElement, forwardRef } from 'react';
|
|
2
|
+
import { OL8_LINK_FORMS, OL8_LINK_SIZES, OL8_LINK_MOTIONS } from '../foundations/geometry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MOLECULE. Figma's five States are visual evidence for review, never props:
|
|
6
|
+
* :hover, :active, :focus-visible, :visited and [aria-current] carry them.
|
|
7
|
+
* Icons are intentionally parked and must not be added as local artwork.
|
|
8
|
+
*/
|
|
9
|
+
export const Link = forwardRef(function Link({
|
|
10
|
+
children, href, form = 'inline', size = 'standard', current, motion = 'system',
|
|
11
|
+
className = '', ...rest
|
|
12
|
+
}, ref) {
|
|
13
|
+
if (!OL8_LINK_FORMS.includes(form)) throw new Error(`[ol8] unknown link form "${form}"`);
|
|
14
|
+
if (!OL8_LINK_SIZES.includes(size)) throw new Error(`[ol8] unknown link size "${size}"`);
|
|
15
|
+
if (!OL8_LINK_MOTIONS.includes(motion)) throw new Error(`[ol8] unknown link motion "${motion}"`);
|
|
16
|
+
if (typeof href !== 'string' || href === '') {
|
|
17
|
+
throw new Error('[ol8] Link requires an href. A link without a destination is a button.');
|
|
18
|
+
}
|
|
19
|
+
if (current !== undefined && form !== 'navigation') {
|
|
20
|
+
throw new Error('[ol8] aria-current belongs to a navigation link, not an inline or standalone one.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return createElement('a', {
|
|
24
|
+
...rest, ref, href,
|
|
25
|
+
className: `ol8-link${className ? ` ${className}` : ''}`,
|
|
26
|
+
'data-ol8-form': form,
|
|
27
|
+
'data-ol8-size': size,
|
|
28
|
+
...(motion === 'none' ? { 'data-ol8-motion': 'none' } : {}),
|
|
29
|
+
...(current ? { 'aria-current': current === true ? 'page' : String(current) } : {}),
|
|
30
|
+
}, children);
|
|
31
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createElement, forwardRef } from 'react';
|
|
2
|
+
import { Icon } from '../atoms/Icon.js';
|
|
3
|
+
import { NavigationBadge } from '../atoms/NavigationBadge.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shared internal molecule behind Tabs, Segmented Control and Tab Bar.
|
|
7
|
+
* Figma draws it three times (688:51, 695:167, 700:292); it is one molecule
|
|
8
|
+
* with three selection languages. The navigation contract keeps it internal, so
|
|
9
|
+
* it is not exported from the package index.
|
|
10
|
+
*/
|
|
11
|
+
export const NavigationItem = forwardRef(function NavigationItem({
|
|
12
|
+
kind, label, icon, badge, layout = 'inline', size = 'standard',
|
|
13
|
+
selected = false, disabled = false, ariaLabel, href, ...rest
|
|
14
|
+
}, ref) {
|
|
15
|
+
if (!label && !icon) throw new Error('[ol8] a navigation item needs a label, an icon, or both.');
|
|
16
|
+
if (!label && !ariaLabel) {
|
|
17
|
+
throw new Error('[ol8] an icon only navigation item requires an ariaLabel, since a glyph is not a name.');
|
|
18
|
+
}
|
|
19
|
+
if (kind === 'destination' && disabled) {
|
|
20
|
+
throw new Error('[ol8] a destination cannot be disabled. A place a person cannot go does not belong in navigation.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const content = createElement('span', { className: 'ol8-nav-item__content' },
|
|
24
|
+
icon ? createElement(Icon, { key: 'i', name: icon, size: 18, className: 'ol8-nav-item__icon' }) : null,
|
|
25
|
+
label ? createElement('span', { key: 'l', className: 'ol8-nav-item__label' }, label) : null,
|
|
26
|
+
badge !== undefined && badge !== null && badge !== ''
|
|
27
|
+
? createElement(NavigationBadge, { key: 'b' }, badge) : null,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const shared = {
|
|
31
|
+
...rest, ref,
|
|
32
|
+
className: `ol8-nav-item ol8-nav-item--${kind}`,
|
|
33
|
+
'data-ol8-layout': layout,
|
|
34
|
+
'data-ol8-size': size,
|
|
35
|
+
'aria-label': ariaLabel,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (kind === 'destination') {
|
|
39
|
+
if (typeof href !== 'string' || href === '') {
|
|
40
|
+
throw new Error('[ol8] a destination requires an href. Tab Bar navigates, so it uses real links.');
|
|
41
|
+
}
|
|
42
|
+
return createElement('a', { ...shared, href, 'aria-current': selected ? 'page' : undefined }, content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return createElement('button', {
|
|
46
|
+
...shared, type: 'button', disabled,
|
|
47
|
+
...(kind === 'tab'
|
|
48
|
+
? { role: 'tab', 'aria-selected': selected }
|
|
49
|
+
: { 'aria-pressed': selected }),
|
|
50
|
+
},
|
|
51
|
+
content,
|
|
52
|
+
// Always present, so selecting does not change the box.
|
|
53
|
+
kind === 'tab' ? createElement('span', { key: 'ind', className: 'ol8-nav-item__indicator', 'aria-hidden': 'true' }) : null,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createElement, forwardRef, useId } from 'react';
|
|
2
|
+
import { OL8_TEXT_FIELD_SIZES, OL8_TEXT_FIELD_APPEARANCES, OL8_TEXT_FIELD_MATERIALS, OL8_MESSAGE_TONES } from '../foundations/geometry.js';
|
|
3
|
+
import { Icon } from '../atoms/Icon.js';
|
|
4
|
+
import { FormMessage } from './FormMessage.js';
|
|
5
|
+
|
|
6
|
+
/** "native='preferred' | 'required' | 'custom-allowed'", default preferred. */
|
|
7
|
+
export const OL8_SELECT_NATIVE_POLICIES = ['preferred', 'required', 'custom-allowed'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MOLECULE. Figma 583:338 Trigger, 875:1778 Select.
|
|
11
|
+
*
|
|
12
|
+
* "Geometry and appearance reuse approved Text Field tokens", and Figma proves
|
|
13
|
+
* it: every bound variable on the trigger is a Text Field one. So Select wears
|
|
14
|
+
* the field's own classes.
|
|
15
|
+
*
|
|
16
|
+
* The contract is native first: "ordinary Select uses a labelled native select
|
|
17
|
+
* whenever its option and appearance requirements fit native behavior", and "a
|
|
18
|
+
* native control is not replaced merely to force pixel identity". This renders
|
|
19
|
+
* a real select, so the platform keeps the keyboard, the type ahead, the picker
|
|
20
|
+
* and the announcements.
|
|
21
|
+
*/
|
|
22
|
+
export const Select = forwardRef(function Select({
|
|
23
|
+
label, options = [], id, size = 'comfortable', appearance = 'outline', material = 'regular',
|
|
24
|
+
showLabel = true, required = false, disabled = false, invalid = false,
|
|
25
|
+
placeholderOption, leadingIcon, instruction, message, messageTone = 'critical',
|
|
26
|
+
native = 'preferred', className = '', ...rest
|
|
27
|
+
}, ref) {
|
|
28
|
+
if (typeof label !== 'string' || label === '') {
|
|
29
|
+
throw new Error('[ol8] Select requires a label. A placeholder is never the label.');
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(options) || options.length === 0) throw new Error('[ol8] Select needs at least one option.');
|
|
32
|
+
if (!OL8_TEXT_FIELD_SIZES.includes(size)) throw new Error(`[ol8] unknown select size "${size}"`);
|
|
33
|
+
if (!OL8_TEXT_FIELD_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown select appearance "${appearance}"`);
|
|
34
|
+
if (!OL8_TEXT_FIELD_MATERIALS.includes(material)) throw new Error(`[ol8] unknown select material "${material}"`);
|
|
35
|
+
if (!OL8_MESSAGE_TONES.includes(messageTone)) throw new Error(`[ol8] unknown message tone "${messageTone}"`);
|
|
36
|
+
if (!OL8_SELECT_NATIVE_POLICIES.includes(native)) throw new Error(`[ol8] unknown native policy "${native}"`);
|
|
37
|
+
if (native === 'custom-allowed') {
|
|
38
|
+
throw new Error('[ol8] a custom select is justified only when the product needs behaviour native Select cannot provide, and only with the complete focus, keyboard, announcement, form, mobile and forced colour contract implemented. Use Combobox, which implements it.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const generated = useId();
|
|
42
|
+
const selectId = id ?? generated;
|
|
43
|
+
const instructionId = instruction ? `${selectId}-instruction` : undefined;
|
|
44
|
+
const messageId = message ? `${selectId}-message` : undefined;
|
|
45
|
+
const describedBy = [instructionId, messageId].filter(Boolean).join(' ') || undefined;
|
|
46
|
+
|
|
47
|
+
// Grouped options keep their source order, the way the popup groups do.
|
|
48
|
+
const groups = [];
|
|
49
|
+
for (const option of options) {
|
|
50
|
+
const name = option.group ?? null;
|
|
51
|
+
const last = groups[groups.length - 1];
|
|
52
|
+
if (last && last.name === name) last.items.push(option);
|
|
53
|
+
else groups.push({ name, items: [option] });
|
|
54
|
+
}
|
|
55
|
+
const renderOption = (option) => createElement('option', {
|
|
56
|
+
key: option.value, value: option.value, disabled: option.disabled,
|
|
57
|
+
}, option.label);
|
|
58
|
+
|
|
59
|
+
return createElement('div', {
|
|
60
|
+
className: `ol8-field ol8-select${className ? ` ${className}` : ''}`,
|
|
61
|
+
'data-ol8-size': size,
|
|
62
|
+
'data-ol8-appearance': appearance,
|
|
63
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
64
|
+
...(disabled ? { 'data-ol8-disabled': 'true' } : {}),
|
|
65
|
+
...(invalid ? { 'data-ol8-invalid': 'true' } : {}),
|
|
66
|
+
},
|
|
67
|
+
showLabel
|
|
68
|
+
? createElement('div', { key: 'label-row', className: 'ol8-field__label-row' },
|
|
69
|
+
createElement('label', { key: 'l', className: 'ol8-field__label', htmlFor: selectId }, label),
|
|
70
|
+
required ? createElement('span', { key: 'r', className: 'ol8-field__requirement', 'aria-hidden': 'true' }, '*') : null,
|
|
71
|
+
)
|
|
72
|
+
: null,
|
|
73
|
+
|
|
74
|
+
createElement('div', { key: 'stack', className: 'ol8-field__control-stack' },
|
|
75
|
+
createElement('div', { className: 'ol8-field__control' },
|
|
76
|
+
leadingIcon ? createElement(Icon, { key: 'i', name: leadingIcon, size: 18, className: 'ol8-field__leading-icon' }) : null,
|
|
77
|
+
createElement('select', {
|
|
78
|
+
key: 'select', ...rest, ref,
|
|
79
|
+
id: selectId,
|
|
80
|
+
className: 'ol8-field__input ol8-select__input',
|
|
81
|
+
required, disabled,
|
|
82
|
+
'aria-label': showLabel ? undefined : label,
|
|
83
|
+
'aria-invalid': invalid || undefined,
|
|
84
|
+
'aria-errormessage': invalid ? messageId : undefined,
|
|
85
|
+
'aria-describedby': describedBy,
|
|
86
|
+
},
|
|
87
|
+
// "placeholderOption only when an unselected value is valid or
|
|
88
|
+
// required validation needs an explicit prompt."
|
|
89
|
+
placeholderOption
|
|
90
|
+
? createElement('option', { key: '__placeholder', value: '', disabled: required }, placeholderOption)
|
|
91
|
+
: null,
|
|
92
|
+
groups.map((group, index) => group.name
|
|
93
|
+
? createElement('optgroup', { key: `g${index}`, label: group.name }, group.items.map(renderOption))
|
|
94
|
+
: group.items.map(renderOption)),
|
|
95
|
+
),
|
|
96
|
+
// Decoration: the native control already tells a screen reader it opens a list.
|
|
97
|
+
createElement(Icon, { key: 'disclosure', name: 'chevron-down', size: 18, className: 'ol8-select__disclosure' }),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
instruction
|
|
102
|
+
? createElement('div', { key: 'instruction', className: 'ol8-field__instruction', id: instructionId }, instruction)
|
|
103
|
+
: null,
|
|
104
|
+
message
|
|
105
|
+
? createElement('div', { key: 'supporting', className: 'ol8-field__supporting' },
|
|
106
|
+
createElement(FormMessage, { tone: messageTone, id: messageId }, message))
|
|
107
|
+
: null,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
@@ -16,7 +16,10 @@ export function SelectionOption({
|
|
|
16
16
|
const optionId = id ?? `ol8-option-${auto}`;
|
|
17
17
|
const labelId = `${optionId}-label`;
|
|
18
18
|
const descId = description ? `${optionId}-desc` : undefined;
|
|
19
|
-
|
|
19
|
+
// A div, not an li: the popup rail is a div with role="listbox", and an li
|
|
20
|
+
// outside a list is invalid markup. The core renderer has always emitted a
|
|
21
|
+
// div here; this had drifted.
|
|
22
|
+
return createElement('div', {
|
|
20
23
|
...rest,
|
|
21
24
|
className: `ol8-option${className ? ` ${className}` : ''}`,
|
|
22
25
|
role: 'option', id: optionId,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createElement, forwardRef, useCallback, useId, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
OL8_TEXT_FIELD_SIZES, OL8_TEXT_FIELD_APPEARANCES, OL8_TEXT_FIELD_MATERIALS,
|
|
4
|
+
OL8_MESSAGE_TONES, OL8_VALIDATION_STATUSES, OL8_CHARACTER_LIMIT_BEHAVIORS,
|
|
5
|
+
} from '../foundations/geometry.js';
|
|
6
|
+
import { Icon } from '../atoms/Icon.js';
|
|
7
|
+
import { FormMessage } from './FormMessage.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MOLECULE. Figma 182:33 Outline, 186:53 Filled, 211:36 and 215:226 Gem.
|
|
11
|
+
*
|
|
12
|
+
* Figma's five Conditions are review evidence, never props. They arrive here as
|
|
13
|
+
* :hover, aria-invalid, readOnly and disabled on the native input, which is also
|
|
14
|
+
* what carries type, value, autofill, IME, undo and dictation.
|
|
15
|
+
*
|
|
16
|
+
* Gem is a material adapter. It changes token bound material and nothing else.
|
|
17
|
+
*/
|
|
18
|
+
export const TextField = forwardRef(function TextField({
|
|
19
|
+
label, id, size = 'comfortable', appearance = 'outline', material = 'regular',
|
|
20
|
+
showLabel = true, required = false, disabled = false, readOnly = false, invalid = false,
|
|
21
|
+
leadingIcon, prefix, suffix, trailingAction,
|
|
22
|
+
instruction, message, messageTone = 'critical', messageIcon = true,
|
|
23
|
+
validationStatus = 'idle', characterLimit, characterLimitBehavior = 'soft',
|
|
24
|
+
value, defaultValue, onChange, className = '', ...rest
|
|
25
|
+
}, ref) {
|
|
26
|
+
if (typeof label !== 'string' || label === '') {
|
|
27
|
+
throw new Error('[ol8] Text Field requires a label. A placeholder is never the label.');
|
|
28
|
+
}
|
|
29
|
+
if (!OL8_TEXT_FIELD_SIZES.includes(size)) throw new Error(`[ol8] unknown text field size "${size}"`);
|
|
30
|
+
if (!OL8_TEXT_FIELD_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown text field appearance "${appearance}"`);
|
|
31
|
+
if (!OL8_TEXT_FIELD_MATERIALS.includes(material)) throw new Error(`[ol8] unknown text field material "${material}"`);
|
|
32
|
+
if (!OL8_MESSAGE_TONES.includes(messageTone)) {
|
|
33
|
+
throw new Error(`[ol8] unknown message tone "${messageTone}". Pending is a validationStatus, not a tone.`);
|
|
34
|
+
}
|
|
35
|
+
if (!OL8_VALIDATION_STATUSES.includes(validationStatus)) {
|
|
36
|
+
throw new Error(`[ol8] unknown validation status "${validationStatus}"`);
|
|
37
|
+
}
|
|
38
|
+
if (!OL8_CHARACTER_LIMIT_BEHAVIORS.includes(characterLimitBehavior)) {
|
|
39
|
+
throw new Error(`[ol8] unknown character limit behavior "${characterLimitBehavior}"`);
|
|
40
|
+
}
|
|
41
|
+
if (characterLimit !== undefined && (!Number.isInteger(characterLimit) || characterLimit <= 0)) {
|
|
42
|
+
throw new Error('[ol8] characterLimit counts graphemes, so it must be a positive whole number.');
|
|
43
|
+
}
|
|
44
|
+
if (readOnly && invalid) {
|
|
45
|
+
throw new Error('[ol8] a read only field cannot be invalid. If a person cannot correct the value, the problem belongs to system feedback rather than to an editable field error.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const generated = useId();
|
|
49
|
+
const fieldId = id ?? generated;
|
|
50
|
+
const instructionId = instruction ? `${fieldId}-instruction` : undefined;
|
|
51
|
+
const messageId = message ? `${fieldId}-message` : undefined;
|
|
52
|
+
const counterId = characterLimit !== undefined ? `${fieldId}-counter` : undefined;
|
|
53
|
+
const describedBy = [instructionId, messageId, counterId].filter(Boolean).join(' ') || undefined;
|
|
54
|
+
|
|
55
|
+
const inputRef = useRef(null);
|
|
56
|
+
const attach = useCallback((node) => {
|
|
57
|
+
inputRef.current = node;
|
|
58
|
+
if (typeof ref === 'function') ref(node);
|
|
59
|
+
else if (ref) ref.current = node;
|
|
60
|
+
}, [ref]);
|
|
61
|
+
|
|
62
|
+
// "Clicking or tapping anywhere inside the control frame focuses the native
|
|
63
|
+
// input, except an independent trailing action."
|
|
64
|
+
const focusInput = useCallback((event) => {
|
|
65
|
+
if (event.target === inputRef.current) return;
|
|
66
|
+
if (event.target.closest?.('.ol8-field__trailing-action')) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
inputRef.current?.focus();
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// A native maxLength counts UTF-16 units, which would cut an emoji in half,
|
|
72
|
+
// so a hard limit is enforced here in graphemes instead.
|
|
73
|
+
const handleChange = useCallback((event) => {
|
|
74
|
+
if (characterLimit !== undefined && characterLimitBehavior === 'hard') {
|
|
75
|
+
const clipped = clipToGraphemes(event.target.value, characterLimit);
|
|
76
|
+
if (clipped !== event.target.value) event.target.value = clipped;
|
|
77
|
+
}
|
|
78
|
+
onChange?.(event);
|
|
79
|
+
}, [characterLimit, characterLimitBehavior, onChange]);
|
|
80
|
+
|
|
81
|
+
const shown = value ?? defaultValue ?? '';
|
|
82
|
+
const tone = validationStatus === 'pending' ? 'pending' : messageTone;
|
|
83
|
+
|
|
84
|
+
return createElement('div', {
|
|
85
|
+
className: `ol8-field${className ? ` ${className}` : ''}`,
|
|
86
|
+
'data-ol8-size': size,
|
|
87
|
+
'data-ol8-appearance': appearance,
|
|
88
|
+
...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
|
|
89
|
+
...(disabled ? { 'data-ol8-disabled': 'true' } : {}),
|
|
90
|
+
...(readOnly ? { 'data-ol8-readonly': 'true' } : {}),
|
|
91
|
+
...(invalid ? { 'data-ol8-invalid': 'true' } : {}),
|
|
92
|
+
...(validationStatus !== 'idle' ? { 'data-ol8-validation': validationStatus } : {}),
|
|
93
|
+
},
|
|
94
|
+
showLabel
|
|
95
|
+
? createElement('div', { key: 'label-row', className: 'ol8-field__label-row' },
|
|
96
|
+
createElement('label', { key: 'label', className: 'ol8-field__label', htmlFor: fieldId }, label),
|
|
97
|
+
required
|
|
98
|
+
? createElement('span', { key: 'req', className: 'ol8-field__requirement', 'aria-hidden': 'true' }, '*')
|
|
99
|
+
: null,
|
|
100
|
+
)
|
|
101
|
+
: null,
|
|
102
|
+
|
|
103
|
+
createElement('div', { key: 'stack', className: 'ol8-field__control-stack' },
|
|
104
|
+
createElement('div', { className: 'ol8-field__control', onMouseDown: focusInput },
|
|
105
|
+
leadingIcon
|
|
106
|
+
? createElement(Icon, { key: 'lead', name: leadingIcon, size: 18, className: 'ol8-field__leading-icon' })
|
|
107
|
+
: null,
|
|
108
|
+
prefix
|
|
109
|
+
? createElement('span', { key: 'prefix', className: 'ol8-field__affix', 'aria-hidden': 'true' }, prefix)
|
|
110
|
+
: null,
|
|
111
|
+
createElement('input', {
|
|
112
|
+
key: 'input', ...rest, ref: attach,
|
|
113
|
+
id: fieldId,
|
|
114
|
+
className: 'ol8-field__input',
|
|
115
|
+
value, defaultValue, onChange: handleChange,
|
|
116
|
+
required, disabled, readOnly,
|
|
117
|
+
'aria-label': showLabel ? undefined : label,
|
|
118
|
+
'aria-invalid': invalid || undefined,
|
|
119
|
+
'aria-errormessage': invalid ? messageId : undefined,
|
|
120
|
+
'aria-describedby': describedBy,
|
|
121
|
+
}),
|
|
122
|
+
suffix
|
|
123
|
+
? createElement('span', { key: 'suffix', className: 'ol8-field__affix', 'aria-hidden': 'true' }, suffix)
|
|
124
|
+
: null,
|
|
125
|
+
trailingAction
|
|
126
|
+
? createElement('button', {
|
|
127
|
+
key: 'trailing', type: 'button', className: 'ol8-field__trailing-action',
|
|
128
|
+
disabled, onClick: trailingAction.onClick, 'aria-label': trailingAction.label,
|
|
129
|
+
'aria-pressed': trailingAction.pressed,
|
|
130
|
+
}, createElement(Icon, { name: trailingAction.icon, size: 18 }))
|
|
131
|
+
: null,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
|
|
135
|
+
instruction
|
|
136
|
+
? createElement('div', { key: 'instruction', className: 'ol8-field__instruction', id: instructionId }, instruction)
|
|
137
|
+
: null,
|
|
138
|
+
|
|
139
|
+
(message || counterId)
|
|
140
|
+
? createElement('div', { key: 'supporting', className: 'ol8-field__supporting' },
|
|
141
|
+
message
|
|
142
|
+
? createElement(FormMessage, { key: 'message', tone, icon: messageIcon, id: messageId }, message)
|
|
143
|
+
: createElement('span', { key: 'spacer', className: 'ol8-field__supporting-spacer' }),
|
|
144
|
+
counterId
|
|
145
|
+
? createElement('span', { key: 'counter', className: 'ol8-field__counter', id: counterId },
|
|
146
|
+
`${countGraphemes(shown)} / ${characterLimit}`)
|
|
147
|
+
: null,
|
|
148
|
+
)
|
|
149
|
+
: null,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Counts what a reader would call characters, so an emoji built from several
|
|
155
|
+
* code points counts as one. The contract asks for extended grapheme clusters
|
|
156
|
+
* rather than UTF-16 code units or bytes.
|
|
157
|
+
*/
|
|
158
|
+
export function countGraphemes(value) {
|
|
159
|
+
return segment(value).length;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Keeps the first `limit` graphemes, so a cluster is never cut in half. */
|
|
163
|
+
export function clipToGraphemes(value, limit) {
|
|
164
|
+
const parts = segment(value);
|
|
165
|
+
return parts.length <= limit ? String(value ?? '') : parts.slice(0, limit).join('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function segment(value) {
|
|
169
|
+
const text = String(value ?? '');
|
|
170
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
171
|
+
return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map(s => s.segment);
|
|
172
|
+
}
|
|
173
|
+
return [...text];
|
|
174
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createElement, useId } from 'react';
|
|
2
|
+
import { IconGlyph } from '../atoms/Icon.js';
|
|
3
|
+
import { ChipShape } from './ChoiceChip.js';
|
|
4
|
+
import { OL8_TOKEN_SIZES, OL8_TOKEN_MARKS, resolveTokenMark } from '../foundations/geometry.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MOLECULE. A committed value. It draws no silhouette of its own: Figma's Token
|
|
8
|
+
* instantiates the Choice Chip Geometry Owner, and so does this. The one axis
|
|
9
|
+
* it adds is the Mark.
|
|
10
|
+
*
|
|
11
|
+
* A Token is not a Choice Chip. A chip is a checkbox a person toggles; a token
|
|
12
|
+
* is a value already committed, so it is ordinary content whose only
|
|
13
|
+
* interactive part is the optional Remove button.
|
|
14
|
+
*/
|
|
15
|
+
export function Token({
|
|
16
|
+
children, size = 'standard', selected = false, mark = 'auto', removable = true,
|
|
17
|
+
removeLabel, focusable = true, onRemove, id, className = '', ...rest
|
|
18
|
+
}) {
|
|
19
|
+
if (!OL8_TOKEN_SIZES.includes(size)) throw new Error(`[ol8] unknown token size "${size}"`);
|
|
20
|
+
if (!OL8_TOKEN_MARKS.includes(mark)) throw new Error(`[ol8] unknown token mark "${mark}"`);
|
|
21
|
+
if (!children) throw new Error('[ol8] Token requires a label');
|
|
22
|
+
if (mark === 'remove' && !removable) {
|
|
23
|
+
throw new Error('[ol8] mark "remove" offers a removal path a non removable token does not have');
|
|
24
|
+
}
|
|
25
|
+
const auto = useId();
|
|
26
|
+
const uid = id ?? `ol8-token-${auto}`;
|
|
27
|
+
const resolved = resolveTokenMark(mark, { removable, selected });
|
|
28
|
+
|
|
29
|
+
// The label stays the token's content: the contract requires the complete
|
|
30
|
+
// value to remain readable, never shortened or overlaid by its mark.
|
|
31
|
+
const markup = resolved === 'remove'
|
|
32
|
+
? createElement('button', {
|
|
33
|
+
key: 'mark', type: 'button', className: 'ol8-chip__mark ol8-token__remove',
|
|
34
|
+
'aria-label': removeLabel ?? `Remove ${children}`, onClick: onRemove,
|
|
35
|
+
...(focusable ? {} : { tabIndex: -1 }),
|
|
36
|
+
}, createElement(IconGlyph, { name: 'close' }))
|
|
37
|
+
: resolved === 'check'
|
|
38
|
+
? createElement('span', { key: 'mark', className: 'ol8-chip__mark', 'aria-hidden': 'true' },
|
|
39
|
+
createElement(IconGlyph, { name: 'check' }))
|
|
40
|
+
: null;
|
|
41
|
+
|
|
42
|
+
return createElement('span', {
|
|
43
|
+
...rest,
|
|
44
|
+
className: `ol8-token ol8-chip${className ? ` ${className}` : ''}`,
|
|
45
|
+
'data-ol8-size': size,
|
|
46
|
+
'data-ol8-selection': selected ? 'selected' : 'inactive',
|
|
47
|
+
'data-ol8-mark': resolved,
|
|
48
|
+
}, createElement(ChipShape, { uid, label: children, mark: markup }));
|
|
49
|
+
}
|
|
@@ -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
|
+
}
|