@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,170 @@
1
+ /**
2
+ * Oneli8 · Choice Chip (MOLECULE) — the Soft Hexagon.
3
+ *
4
+ * Figma: Select and Combobox / Choice Chip / Soft Hexagon 636:847 (8 variants,
5
+ * Size x Selection, plus a Label text property and a Show Mark boolean).
6
+ *
7
+ * From the Figma description:
8
+ * "Compact/Standard/Comfortable/Large use approved 30/36/42/42px visible
9
+ * geometry inside protected 48/48/48/60px targets. Fixed 12px terminals and
10
+ * tokenized 9/12/15/15px inner rail insets preserve one silhouette at every
11
+ * label width."
12
+ *
13
+ * Figma builds the silhouette from three boxes, and so does this: a fixed 12px
14
+ * left terminal, a centre rail that grows with the label, and a mirrored right
15
+ * terminal. Only the rail grows, which is why a short chip and a long chip keep
16
+ * the same shoulder depth and side angle. The contract is explicit that ends
17
+ * which scale with the text are prohibited.
18
+ *
19
+ * WHY SVG RATHER THAN clip-path
20
+ * The terminals are not polygons. Each has three small curves softening the
21
+ * shoulders and the point, and the contract rejects approximating that softness
22
+ * with extra vertices because it reads as an Octagon. `clip-path: polygon()`
23
+ * can only draw straight vertices, so the terminals are real paths.
24
+ *
25
+ * HOW ONE PATH SERVES FOUR HEIGHTS
26
+ * Read from Figma, every terminal path at 30, 36 and 42 has identical X values
27
+ * and Y values that scale exactly with the height: 1/36, 1/12, 5/12, 11/24,
28
+ * 13/24, 7/12, 11/12, 35/36 of the height, at every size. So one viewBox of
29
+ * 12 x 30 with `preserveAspectRatio="none"` and a fixed 12px width reproduces
30
+ * Figma at all four sizes: the height stretches, the width cannot.
31
+ * `vector-effect="non-scaling-stroke"` keeps the edge an even 2px through that
32
+ * uneven stretch.
33
+ *
34
+ * THE OPEN EDGE
35
+ * Figma draws two vectors per terminal: a closed fill and an open outline that
36
+ * "deliberately omits the hidden vertical closing edge", so the rail's own top
37
+ * and bottom borders continue the perimeter without a doubled seam. This keeps
38
+ * that construction. Figma reaches an inside stroke by scaling the outline path
39
+ * to 98.75% of the width; here the edge is clipped to the fill instead, which
40
+ * is the same rendered result stated directly.
41
+ */
42
+ import { renderIconSvg } from '../../atoms/icon/icon.js';
43
+
44
+ export const OL8_CHIP_SIZES = ['compact', 'standard', 'comfortable', 'large'];
45
+
46
+ /**
47
+ * The terminal, in the 12 x 30 space Figma draws it in. The closed path is the
48
+ * fill; the open one is the perimeter, missing the seam edge at x=12.
49
+ */
50
+ export const OL8_CHIP_TERMINAL_FILL =
51
+ 'M12 0 L8.96202541 0 C7.44303812 0 6.43037944 0.83333333 5.92405035 2.5 ' +
52
+ 'L0.45569618 12.5 C-0.15189877 13.75 -0.15189877 16.25 0.45569618 17.5 ' +
53
+ 'L5.92405035 27.5 C6.43037944 29.16666667 7.44303812 30 8.96202541 30 L12 30 Z';
54
+ export const OL8_CHIP_TERMINAL_EDGE =
55
+ 'M12 0 L8.96202541 0 C7.44303812 0 6.43037944 0.83333333 5.92405035 2.5 ' +
56
+ 'L0.45569618 12.5 C-0.15189877 13.75 -0.15189877 16.25 0.45569618 17.5 ' +
57
+ 'L5.92405035 27.5 C6.43037944 29.16666667 7.44303812 30 8.96202541 30 L12 30';
58
+
59
+ /**
60
+ * One terminal. `side` only picks a class; the right terminal is the left one
61
+ * mirrored in CSS, so the two can never drift apart.
62
+ *
63
+ * The focus bands are strokes on the same path, painted before the fill and at
64
+ * double width, so their inner half lands under the fill and the visible half
65
+ * sits outside the silhouette. That is how a ring follows a hexagon instead of
66
+ * boxing it.
67
+ */
68
+ function renderTerminal(uid, side) {
69
+ const clip = `${uid}-clip-${side}`;
70
+ return `<svg class="ol8-chip__terminal ol8-chip__terminal--${side}" viewBox="0 0 12 30" ` +
71
+ `preserveAspectRatio="none" aria-hidden="true" focusable="false">` +
72
+ `<clipPath id="${clip}"><path d="${OL8_CHIP_TERMINAL_FILL}"/></clipPath>` +
73
+ `<path class="ol8-chip__focus-outer" d="${OL8_CHIP_TERMINAL_EDGE}"/>` +
74
+ `<path class="ol8-chip__focus-inner" d="${OL8_CHIP_TERMINAL_EDGE}"/>` +
75
+ `<path class="ol8-chip__fill" d="${OL8_CHIP_TERMINAL_FILL}"/>` +
76
+ `<path class="ol8-chip__edge" d="${OL8_CHIP_TERMINAL_EDGE}" clip-path="url(#${clip})"/>` +
77
+ `</svg>`;
78
+ }
79
+
80
+ /**
81
+ * The silhouette plus its rail contents. Private: Figma names this the
82
+ * "Choice Chip / Geometry Owner" and both the public chip and the Token
83
+ * instantiate it rather than redrawing it.
84
+ *
85
+ * `mark` is markup for the rail's trailing slot, or an empty string. The mark
86
+ * follows the label, which is the anatomy the contract writes down as "Label +
87
+ * optional Remove mark", and one position serves Check and Remove alike so
88
+ * peer marks never end up on opposite sides.
89
+ */
90
+ export function renderChipShape(uid, label, mark = '') {
91
+ return renderTerminal(uid, 'leading') +
92
+ `<span class="ol8-chip__rail">` +
93
+ `<span class="ol8-chip__label">${escapeText(label)}</span>` +
94
+ mark +
95
+ `</span>` +
96
+ renderTerminal(uid, 'trailing');
97
+ }
98
+
99
+ /**
100
+ * A Choice Chip is a real checkbox. The contract is explicit: "Every option is
101
+ * a real Checkbox rendered with the approved quiet Choice Chip appearance",
102
+ * with "native checked state" among the redundant cues, so selection is never
103
+ * carried by colour alone.
104
+ *
105
+ * @param {string} label
106
+ * @param {{size?:string,selected?:boolean,showMark?:boolean,disabled?:boolean,
107
+ * name?:string,value?:string,id?:string,className?:string}} [options]
108
+ */
109
+ export function renderChoiceChip(label, options = {}) {
110
+ const {
111
+ size = 'standard', selected = false, showMark = false, disabled = false,
112
+ name, value, id, className,
113
+ } = options;
114
+
115
+ if (!OL8_CHIP_SIZES.includes(size)) throw new Error(`[ol8] unknown chip size "${size}"`);
116
+ if (!label || !String(label).trim()) throw new Error('[ol8] Choice Chip requires a label');
117
+
118
+ const uid = id ?? `ol8-chip-${Math.random().toString(36).slice(2, 9)}`;
119
+ const mark = showMark
120
+ ? `<span class="ol8-chip__mark" aria-hidden="true">${renderIconSvg('check')}</span>`
121
+ : '';
122
+
123
+ const inputAttrs = [
124
+ 'type="checkbox"', 'class="ol8-chip__input"', `id="${uid}"`,
125
+ name ? `name="${escapeAttr(name)}"` : '',
126
+ value !== undefined ? `value="${escapeAttr(value)}"` : '',
127
+ selected ? 'checked' : '', disabled ? 'disabled' : '',
128
+ ].filter(Boolean).join(' ');
129
+
130
+ const rowAttrs = [
131
+ `class="ol8-chip${className ? ` ${className}` : ''}"`,
132
+ `for="${uid}"`,
133
+ `data-ol8-size="${size}"`,
134
+ `data-ol8-selection="${selected ? 'selected' : 'inactive'}"`,
135
+ `data-ol8-availability="${disabled ? 'disabled' : 'enabled'}"`,
136
+ ].filter(Boolean).join(' ');
137
+
138
+ return `<label ${rowAttrs}><input ${inputAttrs}>${renderChipShape(uid, label, mark)}</label>`;
139
+ }
140
+
141
+ /**
142
+ * Keeps each chip's Selection attribute in step with its input. A chip in a
143
+ * group is an ordinary checkbox, so the browser owns the toggling; this only
144
+ * mirrors the result onto the attribute the stylesheet reads.
145
+ */
146
+ export function hydrateChoiceChips(root = document) {
147
+ const chips = [...root.querySelectorAll('.ol8-chip')];
148
+ for (const chip of chips) {
149
+ const input = chip.querySelector(':scope > .ol8-chip__input');
150
+ if (!input) continue;
151
+ syncChip(chip);
152
+ if (chip.dataset.ol8ChipBound === 'true') continue;
153
+ input.addEventListener('change', () => syncChip(chip));
154
+ chip.dataset.ol8ChipBound = 'true';
155
+ }
156
+ return chips.length;
157
+ }
158
+
159
+ /** Repaints one chip from the live state of its input. */
160
+ export function syncChip(chip) {
161
+ const input = chip.querySelector(':scope > .ol8-chip__input');
162
+ if (!input) return;
163
+ chip.dataset.ol8Selection = input.checked ? 'selected' : 'inactive';
164
+ chip.dataset.ol8Availability = input.disabled ? 'disabled' : 'enabled';
165
+ }
166
+
167
+ /* Private. Exported only so Token can reuse the same escaping; neither name
168
+ reaches the package index. */
169
+ export function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
170
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1,2 @@
1
+ export { OL8_CHIP_SIZES, renderChoiceChip, hydrateChoiceChips, syncChip } from './choice-chip.js';
2
+ export type { Ol8ChipSize, Ol8ChoiceChipOptions } from './choice-chip.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The public surface. The terminal paths, the shape composer and the escape
3
+ * helpers stay module private on purpose: Figma names the terminals "Private
4
+ * Geometry" and the contract is explicit that they "are private construction
5
+ * inside the canonical Choice Chip molecule ... not published cap atoms, layer
6
+ * variants, or independently swappable components". Token reaches them by
7
+ * importing the module directly, exactly as its Figma counterpart instantiates
8
+ * the Geometry Owner.
9
+ */
10
+ export { OL8_CHIP_SIZES, renderChoiceChip, hydrateChoiceChips, syncChip } from './choice-chip.js';
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Oneli8 · Choice Item (MOLECULE)
3
+ * Figma: Documentation / Molecule / Choice Item
4
+ * Checkbox 252:301 (24 variants) · Radio 331:1309 (16) · Switch 825:1591 (16)
5
+ *
6
+ * From the Figma description (252:301):
7
+ * "Selection maps Unchecked/Checked/Mixed to checked/indeterminate;
8
+ * Availability maps enabled/disabled ... The indicator is a shared
9
+ * selection atom, not local artwork. Native input, name/value, required,
10
+ * reset, form submission, and onCheckedChange live in code."
11
+ *
12
+ * So the molecule is: a real <input>, the selection-indicator atom, a label,
13
+ * an optional description — and NOTHING that duplicates native behaviour.
14
+ *
15
+ * ANATOMY (Figma 266:257 / 252:74 / 252:77)
16
+ * <label class=ol8-choice> the whole row is the target
17
+ * input visually hidden, still focusable
18
+ * __surface hover / pressed / gem wash, bleeds 9px inline
19
+ * __indicator slot: indicator wide, label-line tall
20
+ * __content label + optional description
21
+ *
22
+ * ROW GEOMETRY — every size resolves to the same 48px minimum row:
23
+ * size gap indicator slot h label padding-block
24
+ * compact 6 18 18 Body/Small 15
25
+ * standard 9 18 24 Body/Medium 12
26
+ * comfortable 9 24 24 Body/Medium 12
27
+ * large 9 24 30 Body/Large 9
28
+ * padding-block is derived: (48 - slot height) / 2.
29
+ */
30
+
31
+ .ol8-choice {
32
+ --_gap: var(--ol8-component-choice-gap-standard);
33
+ --_slot-h: var(--ol8-font-line-024);
34
+ --_pad: calc((var(--ol8-component-choice-rowminimum) - var(--_slot-h)) / 2);
35
+ --_label-size: var(--ol8-font-size-016);
36
+ --_label-line: var(--ol8-font-line-024);
37
+ --_indicator: var(--ol8-size-icon-small);
38
+ /* The hover / pressed / focus wash bleeds outside the text column. */
39
+ --_bleed: var(--ol8-dimension-scale-009);
40
+
41
+ box-sizing: border-box;
42
+ position: relative;
43
+ display: flex;
44
+ align-items: flex-start;
45
+ gap: var(--_gap);
46
+ min-block-size: var(--ol8-component-choice-rowminimum);
47
+ padding-block: var(--_pad);
48
+ font-family: var(--ol8-font-family-functional);
49
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
50
+ cursor: pointer;
51
+ }
52
+ .ol8-choice[data-ol8-availability="disabled"] { cursor: not-allowed; }
53
+
54
+ /* ---- Size ------------------------------------------------------------ */
55
+ .ol8-choice[data-ol8-size="compact"] {
56
+ --_gap: var(--ol8-component-choice-gap-compact);
57
+ --_slot-h: var(--ol8-font-line-018);
58
+ --_label-size: var(--ol8-font-size-012);
59
+ --_label-line: var(--ol8-font-line-018);
60
+ --_indicator: var(--ol8-size-icon-small);
61
+ }
62
+ .ol8-choice[data-ol8-size="standard"] {
63
+ --_slot-h: var(--ol8-font-line-024);
64
+ --_label-size: var(--ol8-font-size-016);
65
+ --_label-line: var(--ol8-font-line-024);
66
+ --_indicator: var(--ol8-size-icon-small);
67
+ }
68
+ .ol8-choice[data-ol8-size="comfortable"] {
69
+ --_slot-h: var(--ol8-font-line-024);
70
+ --_label-size: var(--ol8-font-size-016);
71
+ --_label-line: var(--ol8-font-line-024);
72
+ --_indicator: var(--ol8-size-icon-standard);
73
+ }
74
+ .ol8-choice[data-ol8-size="large"] {
75
+ --_slot-h: var(--ol8-font-line-030);
76
+ --_label-size: var(--ol8-font-size-021);
77
+ --_label-line: var(--ol8-font-line-030);
78
+ --_indicator: var(--ol8-size-icon-standard);
79
+ }
80
+ /* Compact's label carries the Body/Small tracking. */
81
+ .ol8-choice[data-ol8-size="compact"] .ol8-choice__label { letter-spacing: 1px; }
82
+
83
+ /* ---- The native input owns everything semantic ----------------------- */
84
+ .ol8-choice__input {
85
+ position: absolute;
86
+ inline-size: 1px;
87
+ block-size: 1px;
88
+ opacity: 0;
89
+ pointer-events: none;
90
+ /* No margin reset needed — it is absolutely positioned, so margin cannot
91
+ affect layout. The structure lint would reject one regardless. */
92
+ }
93
+
94
+ /* ---- Wash: hover, pressed, focus, gem -------------------------------- */
95
+ .ol8-choice__surface {
96
+ position: absolute;
97
+ inset-block: 0;
98
+ inset-inline: calc(-1 * var(--_bleed));
99
+ border-radius: var(--ol8-shape-radius-medium);
100
+ pointer-events: none;
101
+ }
102
+ .ol8-choice:hover:not([data-ol8-availability="disabled"]) > .ol8-choice__surface,
103
+ .ol8-choice:active:not([data-ol8-availability="disabled"]) > .ol8-choice__surface {
104
+ background: var(--ol8-color-selection-surface);
105
+ }
106
+
107
+ /* Two nested rings, both visible — outer 3px, inner 3px at a 2px inset.
108
+ This is the same two-tone ring the Icon Button focus tokens describe. */
109
+ .ol8-choice:has(> .ol8-choice__input:focus-visible) > .ol8-choice__surface {
110
+ border: var(--ol8-focus-ring-outerwidth) solid var(--ol8-color-focus-outer);
111
+ }
112
+ .ol8-choice:has(> .ol8-choice__input:focus-visible) > .ol8-choice__surface::after {
113
+ content: "";
114
+ position: absolute;
115
+ inset: 2px;
116
+ border: var(--ol8-focus-ring-innerwidth) solid var(--ol8-color-focus-inner);
117
+ border-radius: 7px;
118
+ }
119
+
120
+ /* ---- Indicator slot -------------------------------------------------- */
121
+ .ol8-choice__indicator {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ position: relative;
126
+ flex: none;
127
+ inline-size: var(--_indicator);
128
+ block-size: var(--_slot-h);
129
+ }
130
+
131
+ /* Invalid draws an extra boundary over the indicator, same shape. */
132
+ .ol8-choice[data-ol8-invalid="true"] .ol8-choice__indicator::after {
133
+ content: "";
134
+ position: absolute;
135
+ inline-size: var(--_indicator);
136
+ block-size: var(--_indicator);
137
+ border: var(--ol8-component-checkbox-boundary) solid var(--ol8-color-feedbackcritical-inlinecontent);
138
+ border-radius: var(--ol8-component-checkbox-radius-compact);
139
+ pointer-events: none;
140
+ }
141
+ .ol8-choice[data-ol8-size="comfortable"][data-ol8-invalid="true"] .ol8-choice__indicator::after,
142
+ .ol8-choice[data-ol8-size="large"][data-ol8-invalid="true"] .ol8-choice__indicator::after {
143
+ border-radius: var(--ol8-component-checkbox-radius-comfortable);
144
+ }
145
+ .ol8-choice--radio[data-ol8-invalid="true"] .ol8-choice__indicator::after {
146
+ border-radius: var(--ol8-shape-radius-full);
147
+ }
148
+
149
+ /* ---- Content --------------------------------------------------------- */
150
+ .ol8-choice__content {
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: var(--ol8-dimension-scale-003);
154
+ flex: 1 0 0;
155
+ min-inline-size: 0;
156
+ position: relative;
157
+ }
158
+ .ol8-choice__label {
159
+ font-size: var(--_label-size);
160
+ line-height: var(--_label-line);
161
+ font-weight: var(--ol8-font-weight-regular);
162
+ color: var(--ol8-color-text-primary);
163
+ overflow-wrap: break-word;
164
+ }
165
+ .ol8-choice__description {
166
+ font-size: var(--ol8-font-size-012);
167
+ line-height: var(--ol8-font-line-018);
168
+ letter-spacing: 0.12px;
169
+ color: var(--ol8-color-text-secondary);
170
+ overflow-wrap: break-word;
171
+ }
172
+ .ol8-choice[data-ol8-availability="disabled"] .ol8-choice__label,
173
+ .ol8-choice[data-ol8-availability="disabled"] .ol8-choice__description {
174
+ color: var(--ol8-color-text-disabled);
175
+ }
176
+
177
+ /* ---- Gem material ----------------------------------------------------
178
+ "Gem is one Boolean material adapter over the same anatomy; Regular
179
+ remains the default." A wash on the row, not a different component. */
180
+ .ol8-choice[data-ol8-material="gem"] > .ol8-choice__surface {
181
+ background: var(--ol8-material-gem-color-quiet-environmentalface);
182
+ border: 1px solid var(--ol8-material-gem-color-cutedge-high);
183
+ border-radius: var(--ol8-shape-radius-standard);
184
+ inset-inline: 0;
185
+ }
186
+ /* The quiet face is clear glass — near-white at 0.18/0.27, not a dark tint —
187
+ so it cannot guarantee what sits behind it at all. Content on it therefore
188
+ takes the stable near-white gem ink rather than body text, and carries its
189
+ own halo, which is the only thing standing between the label and whatever
190
+ the environment happens to be. Unlike the gem text field, the label and
191
+ description here really are on the face: .ol8-choice__surface spans the row
192
+ beneath them. Disabled is excluded — it resolves to the opaque equivalent,
193
+ where the regular disabled ink applies and no halo is wanted. */
194
+ /* ONE GLOW, TWO SYNTAXES.
195
+ Content on Gem means ALL content — glyphs and control geometry alike. Text
196
+ takes the halo through text-shadow; an SVG stroke cannot, so the indicator
197
+ takes the identical halo through chained drop-shadow filters. Both are built
198
+ from the same two tokens and the same four repetitions, which is what makes
199
+ the label's glow and the checkbox's glow read as one field rather than two
200
+ near-misses. Change the radius or the colour and both follow. */
201
+ .ol8-choice[data-ol8-material="gem"] {
202
+ --_gem-glow: 0 0 var(--ol8-material-gem-blur-contentglow)
203
+ var(--ol8-material-gem-color-quiet-contentglow);
204
+ /* Control geometry takes one stack FEWER than text, on purpose. WCAG 1.4.11
205
+ asks 3:1 of a graphical object where 1.4.3 asks 4.5:1 of text, so the
206
+ boundary does not need the fourth repetition — and leaving it off is what
207
+ keeps the indicator blended into the glass instead of ringed. Measured at
208
+ 3px: text in four stacks is 5.06:1, the boundary in three is 3.75:1. */
209
+ --_gem-glow-filter:
210
+ drop-shadow(var(--_gem-glow)) drop-shadow(var(--_gem-glow))
211
+ drop-shadow(var(--_gem-glow));
212
+ }
213
+
214
+ /* DISABLED ON GEM IS ONE GREY, SHARED.
215
+ color.text.disabled is dark in the Light appearance, so a disabled row on
216
+ glass whose enabled content is near-white ended up with a dark label beside
217
+ a dark box — it read broken rather than unavailable. On Gem, disabled text
218
+ and disabled control geometry take the SAME whitish grey, and neither takes
219
+ the halo: the absence of the glow is part of how unavailable reads.
220
+ Disabled content is exempt from 1.4.3, which excludes inactive controls. */
221
+ .ol8-choice[data-ol8-material="gem"][data-ol8-availability="disabled"] .ol8-choice__label,
222
+ .ol8-choice[data-ol8-material="gem"][data-ol8-availability="disabled"] .ol8-choice__description {
223
+ color: var(--ol8-material-gem-color-quiet-contentdisabled);
224
+ text-shadow: none;
225
+ }
226
+ .ol8-choice[data-ol8-material="gem"][data-ol8-availability="disabled"] .ol8-selection {
227
+ --_edge: var(--ol8-material-gem-color-quiet-contentdisabled);
228
+ --_ink: var(--ol8-material-gem-color-quiet-contentdisabled);
229
+ filter: none;
230
+ }
231
+
232
+ /* The unselected control boundary is content, not chrome. It was
233
+ color.border.strong, which is dark in the Light appearance and left the
234
+ checkbox reading as a dark hole in a surface whose text had gone near-white.
235
+ On Gem it takes the same ink as the label. Selected and mixed are untouched:
236
+ those fill with the primary action family and carry their own white mark,
237
+ which already reads on glass. */
238
+ .ol8-choice[data-ol8-material="gem"]:not([data-ol8-availability="disabled"]) .ol8-selection {
239
+ --_edge: var(--ol8-material-gem-color-quiet-content);
240
+ filter: var(--_gem-glow-filter);
241
+ }
242
+
243
+ /* Any iconography sitting on the face takes the token that already exists for
244
+ exactly this and is already stable across appearances. */
245
+ .ol8-choice[data-ol8-material="gem"]:not([data-ol8-availability="disabled"]) .ol8-icon {
246
+ color: var(--ol8-color-icon-on-gem);
247
+ filter: var(--_gem-glow-filter);
248
+ }
249
+
250
+ .ol8-choice[data-ol8-material="gem"]:not([data-ol8-availability="disabled"]) .ol8-choice__label,
251
+ .ol8-choice[data-ol8-material="gem"]:not([data-ol8-availability="disabled"]) .ol8-choice__description {
252
+ color: var(--ol8-material-gem-color-quiet-content);
253
+ /* Stacked three times on purpose. A single shadow at this radius reaches
254
+ only 1.71:1 against near-white ink on a bright plate, and widening the
255
+ radius makes it WEAKER because blur spreads a fixed alpha budget over more
256
+ area. Repeating the same shadow accumulates alpha instead of spreading it:
257
+ 1 - (1 - a)^4. Measured in proof/gem-halo-contrast.html. */
258
+ text-shadow: var(--_gem-glow), var(--_gem-glow),
259
+ var(--_gem-glow), var(--_gem-glow);
260
+ }
261
+ @media (prefers-reduced-transparency: reduce) {
262
+ .ol8-choice[data-ol8-material="gem"] > .ol8-choice__surface {
263
+ background: var(--ol8-material-gem-color-quiet-opaqueequivalent);
264
+ }
265
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Choice Item (MOLECULE) — headless behavior around a real <input>. It does not
3
+ * reimplement toggling, focus or form participation; the native input does all
4
+ * of it. `indeterminate` is the one thing markup cannot express, which is why
5
+ * the Mixed state needs hydration and the other states do not.
6
+ */
7
+ import type { Ol8IconName } from '../../atoms/icon/icons.generated.js';
8
+ import type { Ol8Selection, Ol8SelectionSize } from '../../atoms/selection-indicator/selection-indicator.js';
9
+
10
+ export type Ol8ChoiceKind = 'checkbox' | 'radio' | 'switch';
11
+ export type Ol8ChoiceSize = 'compact' | 'standard' | 'comfortable' | 'large';
12
+
13
+ export declare const OL8_CHOICE_KINDS: readonly Ol8ChoiceKind[];
14
+ export declare const OL8_CHOICE_SIZES: readonly Ol8ChoiceSize[];
15
+
16
+ /** The molecule's four sizes map onto the atom's two. */
17
+ export declare function indicatorSizeFor(size: Ol8ChoiceSize): Ol8SelectionSize;
18
+
19
+ /** Figma's Selection axis, derived from native input state. */
20
+ export declare function selectionStateFor(
21
+ kind: Ol8ChoiceKind,
22
+ state?: { checked?: boolean; mixed?: boolean },
23
+ ): Ol8Selection;
24
+
25
+ export interface Ol8ChoiceItemOptions {
26
+ size?: Ol8ChoiceSize;
27
+ checked?: boolean;
28
+ /** Checkbox only. Renders Mixed; the real indeterminate flag is set on hydrate. */
29
+ mixed?: boolean;
30
+ disabled?: boolean;
31
+ invalid?: boolean;
32
+ /** Associated with aria-describedby, kept out of the accessible name. */
33
+ description?: string;
34
+ name?: string;
35
+ value?: string;
36
+ required?: boolean;
37
+ id?: string;
38
+ material?: 'regular' | 'gem';
39
+ className?: string;
40
+ }
41
+
42
+ export declare function renderChoiceItem(
43
+ kind: Ol8ChoiceKind,
44
+ label: string,
45
+ options?: Ol8ChoiceItemOptions,
46
+ ): string;
47
+
48
+ /** Applies the JS-only indeterminate flag to Mixed rows. Idempotent. */
49
+ export declare function hydrateChoiceItems(root?: ParentNode): void;
50
+
51
+ /** Repaints one row's indicator from its input's current state. */
52
+ export declare function syncIndicator(row: Element): void;
53
+
54
+ export declare function setChoiceMixed(row: Element, mixed: boolean): void;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Oneli8 · Choice Item (MOLECULE) — headless behavior.
3
+ *
4
+ * Figma (252:301): "Native input, name/value, required, reset, form
5
+ * submission, and onCheckedChange live in code." This module is that code —
6
+ * and deliberately nothing more. It does not reimplement toggling, focus or
7
+ * form participation; a real <input> already does all of it.
8
+ *
9
+ * The one thing that genuinely cannot be expressed in markup or CSS is
10
+ * `indeterminate`, which is a JS-only property. That is why the Mixed state
11
+ * needs hydration and the other states do not.
12
+ */
13
+ import { renderSelectionIndicator } from '../../atoms/selection-indicator/selection-indicator.js';
14
+
15
+ export const OL8_CHOICE_KINDS = ['checkbox', 'radio', 'switch'];
16
+ export const OL8_CHOICE_SIZES = ['compact', 'standard', 'comfortable', 'large'];
17
+
18
+ /** The molecule's four sizes map onto the atom's two. */
19
+ export function indicatorSizeFor(size) {
20
+ return size === 'comfortable' || size === 'large' ? 'comfortable' : 'compact';
21
+ }
22
+
23
+ /** Figma's Selection axis, derived from native input state. */
24
+ export function selectionStateFor(kind, { checked = false, mixed = false } = {}) {
25
+ if (kind === 'checkbox') return mixed ? 'mixed' : checked ? 'checked' : 'unchecked';
26
+ if (kind === 'radio') return checked ? 'selected' : 'unselected';
27
+ return checked ? 'on' : 'off';
28
+ }
29
+
30
+ /**
31
+ * @param {'checkbox'|'radio'|'switch'} kind
32
+ * @param {string} label
33
+ * @param {{size?:string,checked?:boolean,mixed?:boolean,disabled?:boolean,invalid?:boolean,
34
+ * description?:string,name?:string,value?:string,required?:boolean,
35
+ * id?:string,material?:string,className?:string}} [options]
36
+ */
37
+ export function renderChoiceItem(kind, label, options = {}) {
38
+ if (!OL8_CHOICE_KINDS.includes(kind)) throw new Error(`[ol8] unknown choice kind "${kind}"`);
39
+ const {
40
+ size = 'standard', checked = false, mixed = false, disabled = false, invalid = false,
41
+ description, name, value, required = false, id, material = 'regular', className,
42
+ } = options;
43
+
44
+ if (!OL8_CHOICE_SIZES.includes(size)) throw new Error(`[ol8] unknown choice size "${size}"`);
45
+ if (mixed && kind !== 'checkbox') throw new Error('[ol8] only a checkbox has a Mixed state');
46
+ if (!label || !String(label).trim()) throw new Error('[ol8] Choice Item requires a label');
47
+
48
+ const uid = id ?? `ol8-choice-${Math.random().toString(36).slice(2, 9)}`;
49
+ const descId = description ? `${uid}-desc` : null;
50
+ const selection = selectionStateFor(kind, { checked, mixed });
51
+
52
+ const inputAttrs = [
53
+ `type="${kind === 'radio' ? 'radio' : 'checkbox'}"`,
54
+ kind === 'switch' ? 'role="switch"' : '',
55
+ `class="ol8-choice__input"`,
56
+ `id="${uid}"`,
57
+ name ? `name="${escapeAttr(name)}"` : '',
58
+ value !== undefined ? `value="${escapeAttr(value)}"` : '',
59
+ checked ? 'checked' : '',
60
+ disabled ? 'disabled' : '',
61
+ required ? 'required' : '',
62
+ invalid ? 'aria-invalid="true"' : '',
63
+ descId ? `aria-describedby="${descId}"` : '',
64
+ ].filter(Boolean).join(' ');
65
+
66
+ const rowAttrs = [
67
+ `class="ol8-choice ol8-choice--${kind}${className ? ` ${className}` : ''}"`,
68
+ `for="${uid}"`,
69
+ `data-ol8-size="${size}"`,
70
+ `data-ol8-kind="${kind}"`,
71
+ `data-ol8-availability="${disabled ? 'disabled' : 'enabled'}"`,
72
+ invalid ? 'data-ol8-invalid="true"' : '',
73
+ mixed ? 'data-ol8-mixed="true"' : '',
74
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
75
+ ].filter(Boolean).join(' ');
76
+
77
+ const indicator = renderSelectionIndicator(kind, {
78
+ selection, size: indicatorSizeFor(size), disabled,
79
+ });
80
+
81
+ return `<label ${rowAttrs}>` +
82
+ `<input ${inputAttrs}>` +
83
+ `<span class="ol8-choice__surface" aria-hidden="true"></span>` +
84
+ `<span class="ol8-choice__indicator">${indicator}</span>` +
85
+ `<span class="ol8-choice__content">` +
86
+ `<span class="ol8-choice__label">${escapeText(label)}</span>` +
87
+ (description ? `<span class="ol8-choice__description" id="${descId}">${escapeText(description)}</span>` : '') +
88
+ `</span></label>`;
89
+ }
90
+
91
+ /**
92
+ * Binds every `.ol8-choice` under `root`. Idempotent.
93
+ * Applies `indeterminate` (JS-only) and keeps the indicator in step with the
94
+ * input — including radios, where selecting one silently clears its siblings.
95
+ */
96
+ export function hydrateChoiceItems(root = document) {
97
+ const rows = [...root.querySelectorAll('.ol8-choice')];
98
+
99
+ for (const row of rows) {
100
+ const input = row.querySelector(':scope > .ol8-choice__input');
101
+ if (!input) continue;
102
+
103
+ input.indeterminate = row.dataset.ol8Mixed === 'true';
104
+ syncIndicator(row);
105
+
106
+ if (row.dataset.ol8ChoiceBound === 'true') continue;
107
+ input.addEventListener('change', () => {
108
+ // Any interaction resolves Mixed; the browser has already cleared
109
+ // `indeterminate` on click, so mirror that in our own state.
110
+ delete row.dataset.ol8Mixed;
111
+ syncIndicator(row);
112
+ // A radio turning on turns its whole group off — repaint the siblings.
113
+ if (input.type === 'radio' && input.name) {
114
+ for (const other of (row.ownerDocument ?? document)
115
+ .querySelectorAll(`input.ol8-choice__input[type="radio"][name="${CSS.escape(input.name)}"]`)) {
116
+ const otherRow = other.closest('.ol8-choice');
117
+ if (otherRow && otherRow !== row) syncIndicator(otherRow);
118
+ }
119
+ }
120
+ });
121
+ row.dataset.ol8ChoiceBound = 'true';
122
+ }
123
+ return rows.length;
124
+ }
125
+
126
+ /** Repaints one row's indicator from the live state of its input. */
127
+ export function syncIndicator(row) {
128
+ const input = row.querySelector(':scope > .ol8-choice__input');
129
+ const slot = row.querySelector(':scope > .ol8-choice__indicator');
130
+ if (!input || !slot) return;
131
+
132
+ const kind = row.dataset.ol8Kind ?? 'checkbox';
133
+ const selection = selectionStateFor(kind, { checked: input.checked, mixed: input.indeterminate });
134
+
135
+ row.dataset.ol8Availability = input.disabled ? 'disabled' : 'enabled';
136
+ slot.innerHTML = renderSelectionIndicator(kind, {
137
+ selection,
138
+ size: indicatorSizeFor(row.dataset.ol8Size ?? 'standard'),
139
+ disabled: input.disabled,
140
+ });
141
+ }
142
+
143
+ /** Sets the Mixed (indeterminate) state on a checkbox row. */
144
+ export function setChoiceMixed(row, mixed) {
145
+ const input = row.querySelector(':scope > .ol8-choice__input');
146
+ if (!input) return;
147
+ input.indeterminate = !!mixed;
148
+ if (mixed) row.dataset.ol8Mixed = 'true'; else delete row.dataset.ol8Mixed;
149
+ syncIndicator(row);
150
+ }
151
+
152
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
153
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './choice-item.js';
@@ -0,0 +1 @@
1
+ export * from './choice-item.js';