@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.
Files changed (40) hide show
  1. package/README.md +34 -4
  2. package/ai-context.json +116 -1
  3. package/package.json +29 -8
  4. package/src/atoms/NavigationBadge.js +19 -0
  5. package/src/foundations/geometry.js +71 -0
  6. package/src/index.d.ts +214 -0
  7. package/src/index.js +14 -0
  8. package/src/molecules/ChoiceChip.js +101 -0
  9. package/src/molecules/FormMessage.js +33 -0
  10. package/src/molecules/Link.js +31 -0
  11. package/src/molecules/NavigationItem.js +55 -0
  12. package/src/molecules/Select.js +109 -0
  13. package/src/molecules/SelectionOption.js +4 -1
  14. package/src/molecules/TextField.js +174 -0
  15. package/src/molecules/Token.js +49 -0
  16. package/src/organisms/ChoicePicker.js +114 -0
  17. package/src/organisms/Combobox.js +122 -0
  18. package/src/organisms/MultiSelectField.js +138 -0
  19. package/src/organisms/SegmentedControl.js +114 -0
  20. package/src/organisms/SelectionPopup.js +73 -0
  21. package/src/organisms/TabBar.js +68 -0
  22. package/src/organisms/Tabs.js +123 -0
  23. package/src/styles/choice-chip.css +216 -0
  24. package/src/styles/choice-picker.css +100 -0
  25. package/src/styles/combobox.css +42 -0
  26. package/src/styles/form-message.css +48 -0
  27. package/src/styles/gem.css +52 -5
  28. package/src/styles/link.css +116 -0
  29. package/src/styles/multi-select-field.css +78 -0
  30. package/src/styles/navigation-badge.css +37 -0
  31. package/src/styles/navigation-item.css +155 -0
  32. package/src/styles/segmented-control.css +115 -0
  33. package/src/styles/select.css +33 -0
  34. package/src/styles/selection-indicator.css +13 -4
  35. package/src/styles/selection-popup.css +92 -0
  36. package/src/styles/tab-bar.css +47 -0
  37. package/src/styles/tabs.css +55 -0
  38. package/src/styles/text-field.css +268 -0
  39. package/src/styles/token.css +65 -0
  40. package/styles.css +18 -2
@@ -0,0 +1,101 @@
1
+ import { createElement, useId } from 'react';
2
+ import { IconGlyph } from '../atoms/Icon.js';
3
+ import { OL8_CHIP_SIZES } from '../foundations/geometry.js';
4
+
5
+ /**
6
+ * MOLECULE. The Soft Hexagon, built the way Figma builds it: a fixed 12 unit
7
+ * terminal, a rail that grows with the label, and a mirrored terminal. Only the
8
+ * rail grows, so peer chips of any length keep one shoulder depth and angle.
9
+ *
10
+ * The terminal paths and the shape are private, as they are in Figma, where the
11
+ * terminals are named "Private Geometry" and the contract says they are "not
12
+ * published cap atoms, layer variants, or independently swappable components".
13
+ * Token imports them from this module; the package index does not export them.
14
+ *
15
+ * One viewBox serves all four heights: read from Figma, every terminal path at
16
+ * 30, 36 and 42 shares identical X values and Y values that are the same
17
+ * fractions of the height, so stretching Y alone is exact.
18
+ */
19
+ const TERMINAL_FILL =
20
+ 'M12 0 L8.96202541 0 C7.44303812 0 6.43037944 0.83333333 5.92405035 2.5 ' +
21
+ 'L0.45569618 12.5 C-0.15189877 13.75 -0.15189877 16.25 0.45569618 17.5 ' +
22
+ 'L5.92405035 27.5 C6.43037944 29.16666667 7.44303812 30 8.96202541 30 L12 30 Z';
23
+ const TERMINAL_EDGE =
24
+ 'M12 0 L8.96202541 0 C7.44303812 0 6.43037944 0.83333333 5.92405035 2.5 ' +
25
+ 'L0.45569618 12.5 C-0.15189877 13.75 -0.15189877 16.25 0.45569618 17.5 ' +
26
+ 'L5.92405035 27.5 C6.43037944 29.16666667 7.44303812 30 8.96202541 30 L12 30';
27
+
28
+ function Terminal({ uid, side }) {
29
+ const clip = `${uid}-clip-${side}`;
30
+ return createElement('svg', {
31
+ className: `ol8-chip__terminal ol8-chip__terminal--${side}`,
32
+ viewBox: '0 0 12 30', preserveAspectRatio: 'none',
33
+ 'aria-hidden': 'true', focusable: 'false',
34
+ }, [
35
+ createElement('clipPath', { key: 'c', id: clip }, createElement('path', { d: TERMINAL_FILL })),
36
+ createElement('path', { key: 'fo', className: 'ol8-chip__focus-outer', d: TERMINAL_EDGE }),
37
+ createElement('path', { key: 'fi', className: 'ol8-chip__focus-inner', d: TERMINAL_EDGE }),
38
+ createElement('path', { key: 'f', className: 'ol8-chip__fill', d: TERMINAL_FILL }),
39
+ createElement('path', { key: 'e', className: 'ol8-chip__edge', d: TERMINAL_EDGE, clipPath: `url(#${clip})` }),
40
+ ]);
41
+ }
42
+
43
+ /**
44
+ * The silhouette and its rail. Figma's "Choice Chip / Geometry Owner".
45
+ * The mark follows the label, which is the anatomy the contract writes down as
46
+ * "Label + optional Remove mark", and one position serves Check and Remove
47
+ * alike so peer marks never end up on opposite sides.
48
+ */
49
+ export function ChipShape({ uid, label, mark = null }) {
50
+ return [
51
+ createElement(Terminal, { key: 'l', uid, side: 'leading' }),
52
+ createElement('span', { key: 'r', className: 'ol8-chip__rail' }, [
53
+ createElement('span', { key: 'label', className: 'ol8-chip__label' }, label),
54
+ mark,
55
+ ]),
56
+ createElement(Terminal, { key: 't', uid, side: 'trailing' }),
57
+ ];
58
+ }
59
+
60
+ /**
61
+ * A Choice Chip is a real checkbox. The contract is explicit that every picker
62
+ * option is "a real Checkbox rendered with the approved quiet Choice Chip
63
+ * appearance", with native checked state among the redundant cues, so selection
64
+ * never rests on colour alone.
65
+ */
66
+ export function ChoiceChip({
67
+ children, size = 'standard', selected, defaultSelected, showMark = false,
68
+ disabled = false, name, value, id, className = '', onChange, ...rest
69
+ }) {
70
+ if (!OL8_CHIP_SIZES.includes(size)) throw new Error(`[ol8] unknown chip size "${size}"`);
71
+ if (!children) throw new Error('[ol8] Choice Chip requires a label');
72
+ const auto = useId();
73
+ const uid = id ?? `ol8-chip-${auto}`;
74
+ const isOn = selected ?? defaultSelected ?? false;
75
+
76
+ return createElement('label', {
77
+ className: `ol8-chip${className ? ` ${className}` : ''}`,
78
+ htmlFor: uid,
79
+ 'data-ol8-size': size,
80
+ 'data-ol8-selection': isOn ? 'selected' : 'inactive',
81
+ 'data-ol8-availability': disabled ? 'disabled' : 'enabled',
82
+ }, [
83
+ createElement('input', {
84
+ key: 'input', ...rest,
85
+ type: 'checkbox', className: 'ol8-chip__input', id: uid, name, value, disabled, onChange,
86
+ // A controlled chip with nothing listening is a display of state, not a
87
+ // control. Saying so is what React asks for, and it is the truth: the
88
+ // chip cannot change anything until someone handles the change.
89
+ ...(selected !== undefined
90
+ ? { checked: selected, ...(onChange ? {} : { readOnly: true }) }
91
+ : defaultSelected !== undefined ? { defaultChecked: defaultSelected } : {}),
92
+ }),
93
+ createElement(ChipShape, {
94
+ key: 'shape', uid, label: children,
95
+ mark: showMark
96
+ ? createElement('span', { key: 'mark', className: 'ol8-chip__mark', 'aria-hidden': 'true' },
97
+ createElement(IconGlyph, { name: 'check' }))
98
+ : null,
99
+ }),
100
+ ]);
101
+ }
@@ -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
- return createElement('li', {
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
+ }