@oneli8/core 1.0.0-beta.4 → 1.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +51 -13
  2. package/ai-context.json +168 -1
  3. package/package.json +18 -3
  4. package/src/atoms/index.d.ts +1 -0
  5. package/src/atoms/index.js +1 -0
  6. package/src/atoms/navigation-badge/index.d.ts +1 -0
  7. package/src/atoms/navigation-badge/index.js +1 -0
  8. package/src/atoms/navigation-badge/navigation-badge.css +35 -0
  9. package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
  10. package/src/atoms/navigation-badge/navigation-badge.js +21 -0
  11. package/src/atoms/selection-indicator/selection-indicator.css +13 -4
  12. package/src/materials/gem/gem.css +52 -5
  13. package/src/molecules/choice-chip/choice-chip.css +214 -0
  14. package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
  15. package/src/molecules/choice-chip/choice-chip.js +170 -0
  16. package/src/molecules/choice-chip/index.d.ts +2 -0
  17. package/src/molecules/choice-chip/index.js +10 -0
  18. package/src/molecules/form-message/form-message.css +46 -0
  19. package/src/molecules/form-message/form-message.d.ts +23 -0
  20. package/src/molecules/form-message/form-message.js +73 -0
  21. package/src/molecules/form-message/index.d.ts +1 -0
  22. package/src/molecules/form-message/index.js +1 -0
  23. package/src/molecules/index.d.ts +6 -0
  24. package/src/molecules/index.js +6 -0
  25. package/src/molecules/link/index.d.ts +1 -0
  26. package/src/molecules/link/index.js +1 -0
  27. package/src/molecules/link/link.css +114 -0
  28. package/src/molecules/link/link.d.ts +27 -0
  29. package/src/molecules/link/link.js +76 -0
  30. package/src/molecules/navigation-item/index.js +1 -0
  31. package/src/molecules/navigation-item/navigation-item.css +153 -0
  32. package/src/molecules/navigation-item/navigation-item.js +95 -0
  33. package/src/molecules/select/index.js +1 -0
  34. package/src/molecules/select/select.css +31 -0
  35. package/src/molecules/select/select.js +127 -0
  36. package/src/molecules/selection-option/selection-option.js +4 -4
  37. package/src/molecules/text-field/index.d.ts +1 -0
  38. package/src/molecules/text-field/index.js +5 -0
  39. package/src/molecules/text-field/text-field.css +266 -0
  40. package/src/molecules/text-field/text-field.d.ts +69 -0
  41. package/src/molecules/text-field/text-field.js +234 -0
  42. package/src/molecules/token/index.d.ts +2 -0
  43. package/src/molecules/token/index.js +1 -0
  44. package/src/molecules/token/token.css +63 -0
  45. package/src/molecules/token/token.d.ts +35 -0
  46. package/src/molecules/token/token.js +83 -0
  47. package/src/organisms/choice-picker/choice-picker.css +98 -0
  48. package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
  49. package/src/organisms/choice-picker/choice-picker.js +201 -0
  50. package/src/organisms/choice-picker/index.d.ts +1 -0
  51. package/src/organisms/choice-picker/index.js +1 -0
  52. package/src/organisms/combobox/combobox.css +40 -0
  53. package/src/organisms/combobox/combobox.js +309 -0
  54. package/src/organisms/combobox/index.js +5 -0
  55. package/src/organisms/index.d.ts +7 -0
  56. package/src/organisms/index.js +7 -0
  57. package/src/organisms/multi-select-field/index.d.ts +1 -0
  58. package/src/organisms/multi-select-field/index.js +1 -0
  59. package/src/organisms/multi-select-field/multi-select-field.css +76 -0
  60. package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
  61. package/src/organisms/multi-select-field/multi-select-field.js +317 -0
  62. package/src/organisms/navigation-keys.js +58 -0
  63. package/src/organisms/segmented-control/index.d.ts +1 -0
  64. package/src/organisms/segmented-control/index.js +1 -0
  65. package/src/organisms/segmented-control/segmented-control.css +113 -0
  66. package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
  67. package/src/organisms/segmented-control/segmented-control.js +163 -0
  68. package/src/organisms/selection-popup/index.js +1 -0
  69. package/src/organisms/selection-popup/selection-popup.css +90 -0
  70. package/src/organisms/selection-popup/selection-popup.js +100 -0
  71. package/src/organisms/tab-bar/index.d.ts +1 -0
  72. package/src/organisms/tab-bar/index.js +1 -0
  73. package/src/organisms/tab-bar/tab-bar.css +45 -0
  74. package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
  75. package/src/organisms/tab-bar/tab-bar.js +104 -0
  76. package/src/organisms/tabs/index.d.ts +1 -0
  77. package/src/organisms/tabs/index.js +1 -0
  78. package/src/organisms/tabs/tabs.css +53 -0
  79. package/src/organisms/tabs/tabs.d.ts +48 -0
  80. package/src/organisms/tabs/tabs.js +152 -0
  81. package/styles.css +18 -2
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Token (MOLECULE) — a committed value. It delegates every bit of geometry to
3
+ * the Choice Chip owner, exactly as the Figma component does, and adds only the
4
+ * Mark axis. A token is content with an optional Remove button, not a control.
5
+ */
6
+ import type { Ol8ChipSize } from '../choice-chip/choice-chip.js';
7
+
8
+ export type Ol8TokenSize = Ol8ChipSize;
9
+ /** Figma draws the last three; `auto` resolves to one of them. */
10
+ export type Ol8TokenMark = 'auto' | 'none' | 'check' | 'remove';
11
+
12
+ export declare const OL8_TOKEN_SIZES: readonly Ol8TokenSize[];
13
+ export declare const OL8_TOKEN_MARKS: readonly Ol8TokenMark[];
14
+
15
+ /** Remove for a removable value, Check for a selected one, None otherwise. */
16
+ export declare function resolveTokenMark(
17
+ mark: Ol8TokenMark,
18
+ state?: { removable?: boolean; selected?: boolean },
19
+ ): Exclude<Ol8TokenMark, 'auto'>;
20
+
21
+ export interface Ol8TokenOptions {
22
+ size?: Ol8TokenSize;
23
+ selected?: boolean;
24
+ mark?: Ol8TokenMark;
25
+ /** A non removable token may not carry a Remove mark. */
26
+ removable?: boolean;
27
+ /** Overrides the default "Remove {label}" accessible name. */
28
+ removeLabel?: string;
29
+ /** False makes the Remove button reachable by arrow key rather than Tab. */
30
+ focusable?: boolean;
31
+ id?: string;
32
+ className?: string;
33
+ }
34
+
35
+ export declare function renderToken(label: string, options?: Ol8TokenOptions): string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Oneli8 · Token (MOLECULE) — a committed value.
3
+ *
4
+ * Figma: Select and Combobox / Molecule / Token 897:2106 (24 variants,
5
+ * Size x Selection x Mark).
6
+ *
7
+ * From the Figma description:
8
+ * "Compact/Standard/Comfortable/Large delegate all 30/36/42/42px geometry to
9
+ * the Soft Hexagon Choice Chip owner. Selection and None/Check/Remove mark
10
+ * contracts remain independent; governed Check and Close atoms are reused
11
+ * instead of duplicated."
12
+ *
13
+ * So this draws no silhouette of its own. It instantiates the Choice Chip's
14
+ * geometry exactly as the Figma component instantiates the Geometry Owner, and
15
+ * adds the one thing that is genuinely its own: the Mark axis.
16
+ *
17
+ * A Token is not a Choice Chip. A chip is a checkbox a person toggles; a token
18
+ * is a value that has already been committed, so it is ordinary content with an
19
+ * optional Remove button rather than a form control.
20
+ *
21
+ * MARK
22
+ * Figma's axis is the concrete None / Check / Remove. The contract adds `auto`
23
+ * as the public default, which resolves rather than draws: "Remove for
24
+ * removable committed values, Check for selected Choice Chips, and None for
25
+ * unselected or informational chips." A variant cannot be `auto`, which is why
26
+ * Figma has no such option and code does.
27
+ */
28
+ import { renderIconSvg } from '../../atoms/icon/icon.js';
29
+ import { renderChipShape, escapeAttr, OL8_CHIP_SIZES } from '../choice-chip/choice-chip.js';
30
+
31
+ export const OL8_TOKEN_SIZES = OL8_CHIP_SIZES;
32
+ /** Figma draws the last three. `auto` resolves to one of them. */
33
+ export const OL8_TOKEN_MARKS = ['auto', 'none', 'check', 'remove'];
34
+
35
+ /** The contract's resolution, stated once so both packages agree. */
36
+ export function resolveTokenMark(mark, { removable = true, selected = false } = {}) {
37
+ if (mark !== 'auto') return mark;
38
+ if (removable) return 'remove';
39
+ return selected ? 'check' : 'none';
40
+ }
41
+
42
+ /**
43
+ * @param {string} label
44
+ * @param {{size?:string,selected?:boolean,mark?:string,removable?:boolean,
45
+ * removeLabel?:string,id?:string,className?:string}} [options]
46
+ */
47
+ export function renderToken(label, options = {}) {
48
+ const {
49
+ size = 'standard', selected = false, mark = 'auto', removable = true,
50
+ removeLabel, focusable = true, id, className,
51
+ } = options;
52
+
53
+ if (!OL8_TOKEN_SIZES.includes(size)) throw new Error(`[ol8] unknown token size "${size}"`);
54
+ if (!OL8_TOKEN_MARKS.includes(mark)) throw new Error(`[ol8] unknown token mark "${mark}"`);
55
+ if (!label || !String(label).trim()) throw new Error('[ol8] Token requires a label');
56
+ if (mark === 'remove' && !removable) {
57
+ throw new Error('[ol8] mark "remove" offers a removal path a non removable token does not have');
58
+ }
59
+
60
+ const uid = id ?? `ol8-token-${Math.random().toString(36).slice(2, 9)}`;
61
+ const resolved = resolveTokenMark(mark, { removable, selected });
62
+
63
+ // The Remove mark is the only interactive part, so it is a real button with
64
+ // its own accessible name. It follows the label, which is the anatomy the
65
+ // contract writes down, and the label stays the token's content: the complete
66
+ // value must remain readable, never shortened or overlaid by its mark.
67
+ const markup = resolved === 'remove'
68
+ ? `<button type="button" class="ol8-chip__mark ol8-token__remove" ` +
69
+ (focusable ? '' : 'tabindex="-1" ') +
70
+ `aria-label="${escapeAttr(removeLabel ?? `Remove ${label}`)}">${renderIconSvg('close')}</button>`
71
+ : resolved === 'check'
72
+ ? `<span class="ol8-chip__mark" aria-hidden="true">${renderIconSvg('check')}</span>`
73
+ : '';
74
+
75
+ const attrs = [
76
+ `class="ol8-token ol8-chip${className ? ` ${className}` : ''}"`,
77
+ `data-ol8-size="${size}"`,
78
+ `data-ol8-selection="${selected ? 'selected' : 'inactive'}"`,
79
+ `data-ol8-mark="${resolved}"`,
80
+ ].join(' ');
81
+
82
+ return `<span ${attrs}>${renderChipShape(uid, label, markup)}</span>`;
83
+ }
@@ -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; }