@oneli8/core 1.0.0-beta.10

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 (125) hide show
  1. package/AGENTS.md +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +8 -0
  4. package/PRINCIPLES.md +48 -0
  5. package/README.md +130 -0
  6. package/ai-context.json +348 -0
  7. package/package.json +100 -0
  8. package/skills/oneli8-figma-to-code/SKILL.md +25 -0
  9. package/skills/oneli8-icons/SKILL.md +36 -0
  10. package/skills/oneli8-ui/SKILL.md +52 -0
  11. package/src/atoms/icon/icon.css +54 -0
  12. package/src/atoms/icon/icon.d.ts +19 -0
  13. package/src/atoms/icon/icon.js +81 -0
  14. package/src/atoms/icon/icons.generated.d.ts +48 -0
  15. package/src/atoms/icon/icons.generated.js +562 -0
  16. package/src/atoms/icon/index.d.ts +2 -0
  17. package/src/atoms/icon/index.js +2 -0
  18. package/src/atoms/index.d.ts +3 -0
  19. package/src/atoms/index.js +3 -0
  20. package/src/atoms/navigation-badge/index.d.ts +1 -0
  21. package/src/atoms/navigation-badge/index.js +1 -0
  22. package/src/atoms/navigation-badge/navigation-badge.css +35 -0
  23. package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
  24. package/src/atoms/navigation-badge/navigation-badge.js +21 -0
  25. package/src/atoms/selection-indicator/index.d.ts +1 -0
  26. package/src/atoms/selection-indicator/index.js +1 -0
  27. package/src/atoms/selection-indicator/selection-indicator.css +160 -0
  28. package/src/atoms/selection-indicator/selection-indicator.d.ts +36 -0
  29. package/src/atoms/selection-indicator/selection-indicator.js +61 -0
  30. package/src/index.d.ts +3 -0
  31. package/src/index.js +3 -0
  32. package/src/materials/gem/gem.css +374 -0
  33. package/src/molecules/button/button.css +193 -0
  34. package/src/molecules/button/button.d.ts +51 -0
  35. package/src/molecules/button/button.js +134 -0
  36. package/src/molecules/button/index.d.ts +1 -0
  37. package/src/molecules/button/index.js +1 -0
  38. package/src/molecules/choice-chip/choice-chip.css +214 -0
  39. package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
  40. package/src/molecules/choice-chip/choice-chip.js +170 -0
  41. package/src/molecules/choice-chip/index.d.ts +2 -0
  42. package/src/molecules/choice-chip/index.js +10 -0
  43. package/src/molecules/choice-item/choice-item.css +265 -0
  44. package/src/molecules/choice-item/choice-item.d.ts +54 -0
  45. package/src/molecules/choice-item/choice-item.js +153 -0
  46. package/src/molecules/choice-item/index.d.ts +1 -0
  47. package/src/molecules/choice-item/index.js +1 -0
  48. package/src/molecules/form-message/form-message.css +46 -0
  49. package/src/molecules/form-message/form-message.d.ts +23 -0
  50. package/src/molecules/form-message/form-message.js +73 -0
  51. package/src/molecules/form-message/index.d.ts +1 -0
  52. package/src/molecules/form-message/index.js +1 -0
  53. package/src/molecules/icon-button/icon-button.css +222 -0
  54. package/src/molecules/icon-button/icon-button.d.ts +40 -0
  55. package/src/molecules/icon-button/icon-button.js +121 -0
  56. package/src/molecules/icon-button/index.d.ts +1 -0
  57. package/src/molecules/icon-button/index.js +1 -0
  58. package/src/molecules/index.d.ts +10 -0
  59. package/src/molecules/index.js +10 -0
  60. package/src/molecules/link/index.d.ts +1 -0
  61. package/src/molecules/link/index.js +1 -0
  62. package/src/molecules/link/link.css +114 -0
  63. package/src/molecules/link/link.d.ts +27 -0
  64. package/src/molecules/link/link.js +76 -0
  65. package/src/molecules/navigation-item/index.js +1 -0
  66. package/src/molecules/navigation-item/navigation-item.css +153 -0
  67. package/src/molecules/navigation-item/navigation-item.js +95 -0
  68. package/src/molecules/select/index.js +1 -0
  69. package/src/molecules/select/select.css +31 -0
  70. package/src/molecules/select/select.js +127 -0
  71. package/src/molecules/selection-option/index.d.ts +1 -0
  72. package/src/molecules/selection-option/index.js +1 -0
  73. package/src/molecules/selection-option/selection-option.css +121 -0
  74. package/src/molecules/selection-option/selection-option.d.ts +42 -0
  75. package/src/molecules/selection-option/selection-option.js +97 -0
  76. package/src/molecules/text-field/index.d.ts +1 -0
  77. package/src/molecules/text-field/index.js +5 -0
  78. package/src/molecules/text-field/text-field.css +266 -0
  79. package/src/molecules/text-field/text-field.d.ts +69 -0
  80. package/src/molecules/text-field/text-field.js +234 -0
  81. package/src/molecules/token/index.d.ts +2 -0
  82. package/src/molecules/token/index.js +1 -0
  83. package/src/molecules/token/token.css +63 -0
  84. package/src/molecules/token/token.d.ts +35 -0
  85. package/src/molecules/token/token.js +83 -0
  86. package/src/organisms/choice-group/choice-group.css +93 -0
  87. package/src/organisms/choice-group/choice-group.d.ts +80 -0
  88. package/src/organisms/choice-group/choice-group.js +159 -0
  89. package/src/organisms/choice-group/index.d.ts +1 -0
  90. package/src/organisms/choice-group/index.js +1 -0
  91. package/src/organisms/choice-picker/choice-picker.css +98 -0
  92. package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
  93. package/src/organisms/choice-picker/choice-picker.js +201 -0
  94. package/src/organisms/choice-picker/index.d.ts +1 -0
  95. package/src/organisms/choice-picker/index.js +1 -0
  96. package/src/organisms/combobox/combobox.css +40 -0
  97. package/src/organisms/combobox/combobox.js +309 -0
  98. package/src/organisms/combobox/index.js +5 -0
  99. package/src/organisms/index.d.ts +8 -0
  100. package/src/organisms/index.js +8 -0
  101. package/src/organisms/multi-select-field/index.d.ts +1 -0
  102. package/src/organisms/multi-select-field/index.js +1 -0
  103. package/src/organisms/multi-select-field/multi-select-field.css +76 -0
  104. package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
  105. package/src/organisms/multi-select-field/multi-select-field.js +317 -0
  106. package/src/organisms/navigation-keys.js +58 -0
  107. package/src/organisms/segmented-control/index.d.ts +1 -0
  108. package/src/organisms/segmented-control/index.js +1 -0
  109. package/src/organisms/segmented-control/segmented-control.css +113 -0
  110. package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
  111. package/src/organisms/segmented-control/segmented-control.js +163 -0
  112. package/src/organisms/selection-popup/index.js +1 -0
  113. package/src/organisms/selection-popup/selection-popup.css +90 -0
  114. package/src/organisms/selection-popup/selection-popup.js +100 -0
  115. package/src/organisms/tab-bar/index.d.ts +1 -0
  116. package/src/organisms/tab-bar/index.js +1 -0
  117. package/src/organisms/tab-bar/tab-bar.css +45 -0
  118. package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
  119. package/src/organisms/tab-bar/tab-bar.js +104 -0
  120. package/src/organisms/tabs/index.d.ts +1 -0
  121. package/src/organisms/tabs/index.js +1 -0
  122. package/src/organisms/tabs/tabs.css +53 -0
  123. package/src/organisms/tabs/tabs.d.ts +48 -0
  124. package/src/organisms/tabs/tabs.js +152 -0
  125. package/styles.css +29 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Oneli8 · Choice Group (ORGANISM) — headless behavior.
3
+ *
4
+ * Figma (273:603): "Aggregate None/Some/All is computed from nested Checkbox
5
+ * molecules; Mixed is never a third preference. Runtime recursion,
6
+ * parent-child synchronization, group naming, required behavior, reset, and
7
+ * announcements live in code."
8
+ *
9
+ * Two consequences drive this module:
10
+ *
11
+ * 1. Aggregate is an OUTPUT. Nothing may author it. `computeAggregate` is the
12
+ * single source, and the parent's indeterminate flag is derived from it.
13
+ * 2. Mixed is never a preference a user can choose. Clicking a Mixed parent
14
+ * resolves the whole subtree to checked — it never cycles back through
15
+ * Mixed.
16
+ *
17
+ * The radio group needs almost nothing: a native radio group already gives
18
+ * arrow-key roving focus, single selection and form submission. Adding JS for
19
+ * those would only break them.
20
+ */
21
+ import { renderChoiceItem, syncIndicator, setChoiceMixed } from '../../molecules/choice-item/choice-item.js';
22
+
23
+ export const OL8_AGGREGATE = ['none', 'some', 'all'];
24
+
25
+ /** @returns {'none'|'some'|'all'} */
26
+ export function computeAggregate(children) {
27
+ const boxes = [...children].filter(c => !c.disabled);
28
+ if (boxes.length === 0) return 'none';
29
+ const checked = boxes.filter(c => c.checked).length;
30
+ if (checked === 0) return 'none';
31
+ return checked === boxes.length ? 'all' : 'some';
32
+ }
33
+
34
+ function shell({ kind, legend, instruction, message, invalid, orientation, id, className, body }) {
35
+ const uid = id ?? `ol8-group-${Math.random().toString(36).slice(2, 9)}`;
36
+ const instrId = instruction ? `${uid}-instr` : null;
37
+ const msgId = message ? `${uid}-msg` : null;
38
+ const described = [instrId, msgId].filter(Boolean).join(' ');
39
+
40
+ const attrs = [
41
+ `class="ol8-choice-group ol8-choice-group--${kind}${className ? ` ${className}` : ''}"`,
42
+ `id="${uid}"`,
43
+ `data-ol8-kind="${kind}"`,
44
+ orientation === 'horizontal' ? 'data-ol8-orientation="horizontal"' : '',
45
+ invalid ? 'data-ol8-invalid="true" aria-invalid="true"' : '',
46
+ described ? `aria-describedby="${described}"` : '',
47
+ ].filter(Boolean).join(' ');
48
+
49
+ return `<fieldset ${attrs}>` +
50
+ `<legend class="ol8-choice-group__legend">${escapeText(legend)}</legend>` +
51
+ (instruction ? `<p class="ol8-choice-group__instruction" id="${instrId}">${escapeText(instruction)}</p>` : '') +
52
+ body +
53
+ (message ? `<p class="ol8-choice-group__message" id="${msgId}"${invalid ? ' role="alert"' : ''}>${escapeText(message)}</p>` : '') +
54
+ `</fieldset>`;
55
+ }
56
+
57
+ /**
58
+ * Radio group. Native semantics do the work; this only builds the shell.
59
+ * @param {{legend:string,name:string,options:Array<{label:string,value:string,description?:string,disabled?:boolean}>,
60
+ * value?:string,instruction?:string,message?:string,invalid?:boolean,disabled?:boolean,
61
+ * orientation?:'vertical'|'horizontal',size?:string,material?:string,required?:boolean,id?:string,className?:string}} config
62
+ */
63
+ export function renderRadioGroup(config) {
64
+ const { legend, name, options, value, instruction, message, invalid = false, disabled = false,
65
+ orientation = 'vertical', size = 'standard', material = 'regular', required = false,
66
+ id, className } = config;
67
+ if (!legend) throw new Error('[ol8] a radio group requires a legend');
68
+ if (!name) throw new Error('[ol8] a radio group requires a name — it is what makes it a group');
69
+ if (!options?.length) throw new Error('[ol8] a radio group requires options');
70
+
71
+ const items = options.map(o => renderChoiceItem('radio', o.label, {
72
+ size, name, value: o.value, material, required,
73
+ checked: value !== undefined && o.value === value,
74
+ disabled: disabled || !!o.disabled,
75
+ invalid, description: o.description,
76
+ })).join('');
77
+
78
+ return shell({ kind: 'radio', legend, instruction, message, invalid, orientation, id, className,
79
+ body: `<div class="ol8-choice-group__options">${items}</div>` });
80
+ }
81
+
82
+ /**
83
+ * Checkbox hierarchy: one parent whose state is computed from its children.
84
+ * The parent is rendered unchecked; hydration derives its real state.
85
+ */
86
+ export function renderCheckboxGroup(config) {
87
+ const { legend, parentLabel, options, instruction, message, invalid = false, disabled = false,
88
+ size = 'standard', material = 'regular', name, id, className } = config;
89
+ if (!legend) throw new Error('[ol8] a checkbox group requires a legend');
90
+ if (!parentLabel) throw new Error('[ol8] a checkbox hierarchy requires a parent label');
91
+ if (!options?.length) throw new Error('[ol8] a checkbox group requires options');
92
+
93
+ const parent = renderChoiceItem('checkbox', parentLabel, {
94
+ size, material, disabled, invalid, className: 'ol8-choice-group__parent',
95
+ });
96
+ const children = options.map(o => renderChoiceItem('checkbox', o.label, {
97
+ size, material, name, value: o.value, checked: !!o.checked,
98
+ disabled: disabled || !!o.disabled, description: o.description,
99
+ className: 'ol8-choice-group__child',
100
+ })).join('');
101
+
102
+ return shell({ kind: 'checkbox', legend, instruction, message, invalid, orientation: 'vertical',
103
+ id, className,
104
+ body: parent + `<div class="ol8-choice-group__children">${children}</div>` });
105
+ }
106
+
107
+ /**
108
+ * Wires every checkbox hierarchy under `root`. Idempotent.
109
+ * Radio groups need no wiring — the browser already owns their behaviour.
110
+ */
111
+ export function hydrateChoiceGroups(root = document) {
112
+ const groups = [...root.querySelectorAll('.ol8-choice-group--checkbox')];
113
+
114
+ for (const group of groups) {
115
+ const parentRow = group.querySelector(':scope > .ol8-choice-group__parent');
116
+ const parent = parentRow?.querySelector(':scope > .ol8-choice__input');
117
+ const children = [...group.querySelectorAll('.ol8-choice-group__child > .ol8-choice__input')];
118
+ if (!parent || children.length === 0) continue;
119
+
120
+ applyAggregate(group, parentRow, parent, children);
121
+
122
+ if (group.dataset.ol8GroupBound === 'true') continue;
123
+
124
+ // Mixed is never a preference: a Mixed parent resolves to all-checked.
125
+ parent.addEventListener('change', () => {
126
+ const target = parent.indeterminate ? true : parent.checked;
127
+ for (const child of children) {
128
+ if (child.disabled) continue;
129
+ child.checked = target;
130
+ syncIndicator(child.closest('.ol8-choice'));
131
+ }
132
+ applyAggregate(group, parentRow, parent, children);
133
+ group.dispatchEvent(new CustomEvent('ol8:aggregatechange', {
134
+ bubbles: true, detail: { aggregate: computeAggregate(children) },
135
+ }));
136
+ });
137
+
138
+ for (const child of children) {
139
+ child.addEventListener('change', () => {
140
+ applyAggregate(group, parentRow, parent, children);
141
+ group.dispatchEvent(new CustomEvent('ol8:aggregatechange', {
142
+ bubbles: true, detail: { aggregate: computeAggregate(children) },
143
+ }));
144
+ });
145
+ }
146
+ group.dataset.ol8GroupBound = 'true';
147
+ }
148
+ return groups.length;
149
+ }
150
+
151
+ function applyAggregate(group, parentRow, parent, children) {
152
+ const aggregate = computeAggregate(children);
153
+ group.dataset.ol8Aggregate = aggregate;
154
+ parent.checked = aggregate === 'all';
155
+ setChoiceMixed(parentRow, aggregate === 'some');
156
+ syncIndicator(parentRow);
157
+ }
158
+
159
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './choice-group.js';
@@ -0,0 +1 @@
1
+ export * from './choice-group.js';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Oneli8 · Choice Picker (ORGANISM)
3
+ * Figma: Organism / Choice Picker / Soft Hexagon 906:2898 (16 variants)
4
+ *
5
+ * Three parts stacked twelve apart: a search field, a wrapping matrix of chips,
6
+ * and in Apply mode a commitment line. The search field is the canonical Text
7
+ * Field owner and the chips are the canonical Choice Chip owner, so neither is
8
+ * restyled here.
9
+ *
10
+ * Read from all four sizes in Figma: the root gap, the matrix gap and the
11
+ * commitment typography do NOT scale with the picker size. They are twelve,
12
+ * six, and 12/18 at Compact, Standard, Comfortable and Large alike. Only the
13
+ * search field and the chips take the size.
14
+ */
15
+
16
+ .ol8-picker {
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: var(--ol8-spacing-stack-related);
20
+ inline-size: 100%;
21
+ }
22
+
23
+ /* "The matrix wraps with 6px gaps and preserves source/reading order; it is not
24
+ a masonry layout." A plain wrapping flex row is exactly that. */
25
+ .ol8-picker__matrix {
26
+ display: flex;
27
+ flex-wrap: wrap;
28
+ gap: var(--ol8-component-choice-chip-gap);
29
+ }
30
+
31
+ /* Figma: HORIZONTAL, space between, 24 tall, both texts secondary at 12/18. */
32
+ .ol8-picker__commitment {
33
+ display: flex;
34
+ align-items: flex-start;
35
+ justify-content: space-between;
36
+ gap: var(--ol8-spacing-inline-standard);
37
+ min-block-size: var(--ol8-size-icon-standard);
38
+ font-size: var(--ol8-font-size-012);
39
+ line-height: var(--ol8-font-line-018);
40
+ color: var(--ol8-color-text-secondary);
41
+ }
42
+
43
+ .ol8-picker__summary {
44
+ font-family: var(--ol8-font-family-functional);
45
+ color: inherit;
46
+ }
47
+
48
+ /* Clear all and the apply hint are text weight actions rather than Button
49
+ instances. The contract asks for Clear all to be quiet, and Figma's row is
50
+ twenty four tall, which a Button's protected forty eight target would break.
51
+ They are still real buttons, so they are reachable and announced. */
52
+ .ol8-picker__apply,
53
+ .ol8-picker__clear {
54
+ padding: 0;
55
+ border: 0;
56
+ background: none;
57
+ font-family: var(--ol8-font-family-functional);
58
+ font-size: inherit;
59
+ line-height: inherit;
60
+ color: var(--ol8-color-actionquiet-contentdefault);
61
+ cursor: pointer;
62
+ }
63
+ .ol8-picker__apply:hover,
64
+ .ol8-picker__clear:hover {
65
+ color: var(--ol8-color-actionquiet-contenthover);
66
+ text-decoration: underline;
67
+ }
68
+ .ol8-picker__apply:focus-visible,
69
+ .ol8-picker__clear:focus-visible {
70
+ outline: var(--ol8-focus-ring-innerwidth) solid var(--ol8-color-focus-inner);
71
+ outline-offset: var(--ol8-focus-ring-offset);
72
+ }
73
+
74
+ .ol8-picker[data-ol8-availability="disabled"] .ol8-picker__apply,
75
+ .ol8-picker[data-ol8-availability="disabled"] .ol8-picker__clear {
76
+ color: var(--ol8-color-actionquiet-contentdisabled);
77
+ cursor: not-allowed;
78
+ }
79
+
80
+ /* The search label stays associated while the composition hides it. */
81
+ .ol8-picker__label-row--hidden {
82
+ position: absolute;
83
+ inline-size: 1px;
84
+ block-size: 1px;
85
+ overflow: hidden;
86
+ clip-path: inset(50%);
87
+ white-space: nowrap;
88
+ }
89
+
90
+ /* Retrieval and result counts are announced rather than only drawn. */
91
+ .ol8-picker__status {
92
+ position: absolute;
93
+ inline-size: 1px;
94
+ block-size: 1px;
95
+ overflow: hidden;
96
+ clip-path: inset(50%);
97
+ white-space: nowrap;
98
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Choice Picker (ORGANISM) — search over a wrapping matrix of choices.
3
+ * The inline composition is canonical: an accessible group holding a search
4
+ * field, real checkboxes wearing the Choice Chip appearance, and, in Apply
5
+ * mode, a commitment line. It is deliberately not a dialog pretending to be a
6
+ * listbox, because the surface holds search, many choices and actions.
7
+ */
8
+ export type Ol8PickerSize = 'compact' | 'standard' | 'comfortable' | 'large';
9
+ export type Ol8PickerMaterial = 'regular' | 'gem';
10
+ export type Ol8CommitBehavior = 'immediate' | 'apply';
11
+ export type Ol8PickerPresentation = 'inline' | 'popup';
12
+ /** One treatment across an instance; peers never mix Check and None. */
13
+ export type Ol8PickerMark = 'check' | 'none';
14
+
15
+ export declare const OL8_PICKER_SIZES: readonly Ol8PickerSize[];
16
+ export declare const OL8_PICKER_MATERIALS: readonly Ol8PickerMaterial[];
17
+ export declare const OL8_COMMIT_BEHAVIORS: readonly Ol8CommitBehavior[];
18
+ export declare const OL8_PICKER_PRESENTATIONS: readonly Ol8PickerPresentation[];
19
+ export declare const OL8_PICKER_MARKS: readonly Ol8PickerMark[];
20
+
21
+ export interface Ol8PickerOption {
22
+ value: string;
23
+ label: string;
24
+ selected?: boolean;
25
+ disabled?: boolean;
26
+ }
27
+
28
+ export interface Ol8ChoicePickerOptions {
29
+ /** Names the search. Kept associated even when visually hidden. */
30
+ label: string;
31
+ id?: string;
32
+ name?: string;
33
+ options?: Ol8PickerOption[];
34
+ /** Apply holds a pending set; immediate commits each toggle at once. */
35
+ commitBehavior?: Ol8CommitBehavior;
36
+ presentation?: Ol8PickerPresentation;
37
+ mark?: Ol8PickerMark;
38
+ size?: Ol8PickerSize;
39
+ material?: Ol8PickerMaterial;
40
+ inputValue?: string;
41
+ placeholder?: string;
42
+ showLabel?: boolean;
43
+ selectedSummary?: string;
44
+ /** Apply mode only. Immediate mode rejects it rather than drawing a lie. */
45
+ applyHint?: string;
46
+ showClearAll?: boolean;
47
+ clearAllLabel?: string;
48
+ disabled?: boolean;
49
+ readOnly?: boolean;
50
+ status?: 'none' | 'loading' | 'empty' | 'error';
51
+ statusText?: string;
52
+ className?: string;
53
+ }
54
+
55
+ export declare function renderChoicePicker(options: Ol8ChoicePickerOptions): string;
56
+
57
+ /**
58
+ * Apply mode only. Enter commits the pending set, Escape restores the last
59
+ * committed one without closing or moving focus, and a composing IME is left
60
+ * alone. Both dispatch events rather than mutating state.
61
+ */
62
+ export declare function hydrateChoicePickers(root?: ParentNode): number;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Oneli8 · Choice Picker (ORGANISM)
3
+ *
4
+ * Figma: Organism / Choice Picker / Soft Hexagon 906:2898 (16 variants,
5
+ * Material x Commitment x Size).
6
+ *
7
+ * From the Figma description:
8
+ * "Choice Picker organism using canonical search-field and Soft Hexagon
9
+ * Choice Chip owners. Size propagates through all children; Commitment
10
+ * controls Apply versus Immediate behavior."
11
+ *
12
+ * And from the maintainer decision recorded on the same component:
13
+ * "Public ChoicePicker defaults to presentation=inline ... Search, existing
14
+ * Choice Chip/Token molecules and commitment summary remain in place. Apply
15
+ * mode holds pending selection; Enter from Search or a checkbox commits it,
16
+ * Escape restores the last committed set without closing the inline panel.
17
+ * The Enter-to-apply hint is also a focusable clickable action ... Immediate
18
+ * mode commits each toggle and omits that action."
19
+ *
20
+ * So the inline composition is canonical, and it is a group rather than a
21
+ * dialog: a search field, a wrapping matrix of real checkboxes wearing the
22
+ * chip appearance, and a commitment line that exists only in Apply mode.
23
+ *
24
+ * THE HINT IS A BUTTON, NOT A SENTENCE
25
+ * Figma draws "Enter to apply" as text. The decision above requires it to be an
26
+ * actual clickable and focusable action, because a keyboard instruction is not
27
+ * an affordance for anyone using a pointer, touch, or a screen reader. So it is
28
+ * a button that reads as the hint.
29
+ */
30
+ import { renderChoiceChip } from '../../molecules/choice-chip/choice-chip.js';
31
+ import { renderIcon } from '../../atoms/icon/icon.js';
32
+
33
+ export const OL8_PICKER_SIZES = ['compact', 'standard', 'comfortable', 'large'];
34
+ export const OL8_PICKER_MATERIALS = ['regular', 'gem'];
35
+ export const OL8_COMMIT_BEHAVIORS = ['immediate', 'apply'];
36
+ export const OL8_PICKER_PRESENTATIONS = ['inline', 'popup'];
37
+ /** One mark treatment across an instance; peers never mix Check and None. */
38
+ export const OL8_PICKER_MARKS = ['check', 'none'];
39
+
40
+ /**
41
+ * @param {{
42
+ * label:string, id?:string, name?:string,
43
+ * options?:Array<{value:string,label:string,selected?:boolean,disabled?:boolean}>,
44
+ * commitBehavior?:string, presentation?:string, mark?:string,
45
+ * size?:string, material?:string,
46
+ * inputValue?:string, placeholder?:string, showLabel?:boolean,
47
+ * selectedSummary?:string, applyHint?:string,
48
+ * showClearAll?:boolean, clearAllLabel?:string,
49
+ * disabled?:boolean, readOnly?:boolean,
50
+ * status?:string, statusText?:string, className?:string,
51
+ * }} options
52
+ */
53
+ export function renderChoicePicker(options = {}) {
54
+ const {
55
+ label, id, name, options: items = [],
56
+ commitBehavior = 'immediate', presentation = 'inline', mark = 'check',
57
+ size = 'comfortable', material = 'regular',
58
+ inputValue = '', placeholder, showLabel = false,
59
+ selectedSummary, applyHint = 'Enter to apply',
60
+ showClearAll = false, clearAllLabel = 'Clear all',
61
+ disabled = false, readOnly = false,
62
+ status = 'none', statusText, className,
63
+ } = options;
64
+
65
+ if (!label || !String(label).trim()) throw new Error('[ol8] a Choice Picker requires a label for its search');
66
+ if (!OL8_PICKER_SIZES.includes(size)) throw new Error(`[ol8] unknown size "${size}"`);
67
+ if (!OL8_PICKER_MATERIALS.includes(material)) throw new Error(`[ol8] unknown material "${material}"`);
68
+ if (!OL8_COMMIT_BEHAVIORS.includes(commitBehavior)) throw new Error(`[ol8] unknown commit behavior "${commitBehavior}"`);
69
+ if (!OL8_PICKER_PRESENTATIONS.includes(presentation)) throw new Error(`[ol8] unknown presentation "${presentation}"`);
70
+ if (!OL8_PICKER_MARKS.includes(mark)) throw new Error(`[ol8] unknown mark "${mark}"`);
71
+ if (commitBehavior === 'immediate' && options.applyHint !== undefined) {
72
+ throw new Error('[ol8] immediate commitment applies every toggle at once, so an apply action would be a lie');
73
+ }
74
+ if (status !== 'none' && !statusText) {
75
+ throw new Error('[ol8] a picker status needs words. A spinner alone tells a screen reader nothing.');
76
+ }
77
+
78
+ const uid = id ?? `ol8-picker-${Math.random().toString(36).slice(2, 9)}`;
79
+ const labelId = `${uid}-label`;
80
+ const statusId = `${uid}-status`;
81
+ const selected = items.filter(o => o.selected);
82
+
83
+ // Every option is a real checkbox wearing the chip appearance. The mark
84
+ // treatment is the same for every peer, which is why it is one setting on
85
+ // the picker rather than a property of each chip.
86
+ const matrix = items.map((o, i) => renderChoiceChip(o.label, {
87
+ size,
88
+ selected: !!o.selected,
89
+ showMark: mark === 'check' && !!o.selected,
90
+ disabled: disabled || readOnly || o.disabled,
91
+ name: name ? `${name}[]` : undefined,
92
+ value: o.value,
93
+ id: `${uid}-option-${i}`,
94
+ className: 'ol8-picker__chip',
95
+ })).join('');
96
+
97
+ const summary = selectedSummary
98
+ ?? `${selected.length} selected`;
99
+
100
+ // Apply mode holds a pending set, so it says what is pending and offers the
101
+ // way to commit it. Immediate mode has nothing pending and says nothing.
102
+ const commitment = commitBehavior === 'apply'
103
+ ? `<div class="ol8-picker__commitment">` +
104
+ `<span class="ol8-picker__summary">${escapeText(summary)}</span>` +
105
+ (showClearAll
106
+ ? `<button type="button" class="ol8-picker__clear">${escapeText(clearAllLabel)}</button>`
107
+ : '') +
108
+ `<button type="button" class="ol8-picker__apply">${escapeText(applyHint)}</button>` +
109
+ `</div>`
110
+ : '';
111
+
112
+ const search =
113
+ `<div class="ol8-field ol8-picker__field" data-ol8-size="${size}" data-ol8-appearance="outline"` +
114
+ (material === 'gem' ? ' data-ol8-material="gem"' : '') + '>' +
115
+ `<div class="ol8-field__label-row${showLabel ? '' : ' ol8-picker__label-row--hidden'}">` +
116
+ `<label class="ol8-field__label" id="${labelId}" for="${uid}">${escapeText(label)}</label>` +
117
+ `</div>` +
118
+ `<div class="ol8-field__control-stack"><div class="ol8-field__control">` +
119
+ renderIcon('search', { size: 18, className: 'ol8-field__leading-icon' }) +
120
+ `<input class="ol8-field__input ol8-picker__input" id="${uid}" type="search"` +
121
+ ` value="${escapeAttr(inputValue)}"` +
122
+ (placeholder ? ` placeholder="${escapeAttr(placeholder)}"` : '') +
123
+ (disabled ? ' disabled' : '') + (readOnly ? ' readonly' : '') +
124
+ ` aria-describedby="${statusId}">` +
125
+ `</div></div>` +
126
+ `</div>`;
127
+
128
+ const rootAttrs = [
129
+ `class="ol8-picker${className ? ` ${className}` : ''}"`,
130
+ `data-ol8-size="${size}"`,
131
+ `data-ol8-commitment="${commitBehavior}"`,
132
+ `data-ol8-presentation="${presentation}"`,
133
+ `data-ol8-mark="${mark}"`,
134
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
135
+ disabled ? 'data-ol8-availability="disabled"' : '',
136
+ readOnly ? 'data-ol8-readonly="true"' : '',
137
+ // A group, not a dialog: the surface holds search, many independent
138
+ // choices and actions, so pretending it is one listbox would be a lie.
139
+ `role="group" aria-labelledby="${labelId}"`,
140
+ ].filter(Boolean).join(' ');
141
+
142
+ return `<div ${rootAttrs}>` +
143
+ search +
144
+ `<div class="ol8-picker__matrix">${matrix}</div>` +
145
+ commitment +
146
+ `<span class="ol8-picker__status" id="${statusId}" role="status" aria-live="polite">` +
147
+ escapeText(status !== 'none' ? statusText : '') +
148
+ `</span>` +
149
+ `</div>`;
150
+ }
151
+
152
+ /**
153
+ * Binds every picker under `root`. Idempotent.
154
+ *
155
+ * Only Apply mode needs behaviour: it holds a pending set, so Enter commits it
156
+ * and Escape restores the last committed one without closing the panel or
157
+ * moving focus. Immediate mode has nothing to hold, so a toggle is the whole
158
+ * interaction and the browser already does it.
159
+ *
160
+ * Both dispatch events rather than mutating state, because the selection
161
+ * belongs to the application.
162
+ */
163
+ export function hydrateChoicePickers(root = document) {
164
+ const pickers = [...root.querySelectorAll('.ol8-picker')];
165
+
166
+ for (const picker of pickers) {
167
+ if (picker.dataset.ol8PickerBound === 'true') continue;
168
+ if (picker.dataset.ol8Commitment !== 'apply') { picker.dataset.ol8PickerBound = 'true'; continue; }
169
+
170
+ const commit = () => picker.dispatchEvent(new CustomEvent('ol8:apply', { bubbles: true }));
171
+ const restore = () => picker.dispatchEvent(new CustomEvent('ol8:cancel', { bubbles: true }));
172
+
173
+ picker.addEventListener('keydown', (event) => {
174
+ // A composing IME uses Enter to choose a candidate. Committing there
175
+ // would apply a selection the person was in the middle of typing.
176
+ if (event.isComposing || event.keyCode === 229) return;
177
+ const from = event.target;
178
+ const inSearch = from.classList && from.classList.contains('ol8-picker__input');
179
+ const inChip = from.closest && from.closest('.ol8-picker__chip');
180
+ if (event.key === 'Enter' && (inSearch || inChip)) {
181
+ event.preventDefault();
182
+ commit();
183
+ } else if (event.key === 'Escape') {
184
+ event.preventDefault();
185
+ restore(); // focus deliberately stays put
186
+ }
187
+ });
188
+
189
+ const apply = picker.querySelector(':scope .ol8-picker__apply');
190
+ if (apply) apply.addEventListener('click', commit);
191
+ const clear = picker.querySelector(':scope .ol8-picker__clear');
192
+ if (clear) clear.addEventListener('click', () =>
193
+ picker.dispatchEvent(new CustomEvent('ol8:clearall', { bubbles: true })));
194
+
195
+ picker.dataset.ol8PickerBound = 'true';
196
+ }
197
+ return pickers.length;
198
+ }
199
+
200
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
201
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './choice-picker.js';
@@ -0,0 +1 @@
1
+ export * from './choice-picker.js';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Oneli8 · Combobox (ORGANISM)
3
+ * Figma: Organism / Combobox 883:2159 — "Reuses approved Text Field, Selection
4
+ * Popup and governed icon atoms."
5
+ *
6
+ * So the field geometry is the field's, the popup is the popup's, and this
7
+ * file adds only what belongs to the combination: where the popup sits, and
8
+ * the two marks at the trailing edge.
9
+ */
10
+
11
+ .ol8-combobox { position: relative; }
12
+
13
+ /* "Closed control to popup block gap" is a token, so the popup sits where the
14
+ design says rather than wherever absolute positioning lands it. */
15
+ .ol8-combobox__popup {
16
+ position: absolute;
17
+ z-index: 1;
18
+ inset-inline: 0;
19
+ inset-block-start: calc(100% + var(--ol8-component-selectionpopup-gap));
20
+ }
21
+
22
+ .ol8-combobox__disclosure {
23
+ flex: none;
24
+ display: inline-flex;
25
+ color: var(--ol8-color-icon-secondary);
26
+ cursor: pointer;
27
+ }
28
+
29
+ .ol8-combobox__clear {
30
+ flex: none;
31
+ display: inline-flex;
32
+ border: 0;
33
+ padding: 0;
34
+ background: none;
35
+ color: var(--ol8-color-icon-secondary);
36
+ cursor: pointer;
37
+ }
38
+ .ol8-combobox__clear:disabled { color: var(--ol8-color-icon-disabled); cursor: not-allowed; }
39
+
40
+ .ol8-combobox[data-ol8-disabled] .ol8-combobox__disclosure { color: var(--ol8-color-icon-disabled); cursor: not-allowed; }