@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,234 @@
1
+ /**
2
+ * Oneli8 · Text Field — headless behavior.
3
+ *
4
+ * Two sources govern this file and they govern different things.
5
+ *
6
+ * Figma (182:33 Outline, 186:53 Filled, 211:36 and 215:226 Gem) governs what is
7
+ * drawn: anatomy, geometry, colour and material. Its description is explicit
8
+ * that "runtime input type, name, value, autofill, IME, validationStatus,
9
+ * grapheme limiting, required/disabled/readOnly and native input semantics live
10
+ * in code". Its five Conditions are review evidence, never classes: here they
11
+ * are :hover, aria-invalid, readonly and :disabled on the real input.
12
+ *
13
+ * The Text Field contract governs what the API means: the property vocabulary,
14
+ * the defaults, the state precedence and the prohibitions. Figma has no opinion
15
+ * on those, so there is nothing to reconcile.
16
+ */
17
+ import { renderIcon } from '../../atoms/icon/index.js';
18
+ import { renderFormMessage } from '../form-message/index.js';
19
+
20
+ export const OL8_TEXT_FIELD_SIZES = ['compact', 'standard', 'comfortable', 'large'];
21
+ export const OL8_TEXT_FIELD_APPEARANCES = ['outline', 'filled'];
22
+ export const OL8_TEXT_FIELD_MATERIALS = ['regular', 'gem'];
23
+
24
+ /** The four semantic tones a message can carry. Pending is not one of them. */
25
+ export const OL8_MESSAGE_TONES = ['critical', 'caution', 'positive', 'informative'];
26
+
27
+ /** "Pending is a validation process status, not a fifth message tone." */
28
+ export const OL8_VALIDATION_STATUSES = ['idle', 'pending', 'resolved'];
29
+
30
+ /** Soft lets a person overshoot and then correct. Hard is for real technical limits. */
31
+ export const OL8_CHARACTER_LIMIT_BEHAVIORS = ['soft', 'hard'];
32
+
33
+ let sequence = 0;
34
+ const nextId = () => `ol8-field-${++sequence}`;
35
+
36
+ /** @param {import('./text-field.js').Ol8TextFieldOptions} options */
37
+ export function renderTextField(options = {}) {
38
+ const {
39
+ label, name, id = nextId(), type = 'text', value, placeholder,
40
+ size = 'comfortable', appearance = 'outline', material = 'regular',
41
+ showLabel = true, required = false, disabled = false, readOnly = false, invalid = false,
42
+ leadingIcon, prefix, suffix, trailingAction,
43
+ instruction, message, messageTone = 'critical', messageIcon = true,
44
+ validationStatus = 'idle', characterLimit, characterLimitBehavior = 'soft',
45
+ className,
46
+ } = options;
47
+
48
+ assertContract({ label, size, appearance, material, messageTone, validationStatus,
49
+ characterLimit, characterLimitBehavior, readOnly, invalid });
50
+
51
+ const instructionId = instruction ? `${id}-instruction` : null;
52
+ const messageId = message ? `${id}-message` : null;
53
+ const counterId = characterLimit !== undefined ? `${id}-counter` : null;
54
+ const describedBy = [instructionId, messageId, counterId].filter(Boolean).join(' ');
55
+
56
+ const labelRow = showLabel
57
+ ? `<div class="ol8-field__label-row">` +
58
+ `<label class="ol8-field__label" for="${escapeAttr(id)}">${escapeText(label)}</label>` +
59
+ (required ? `<span class="ol8-field__requirement" aria-hidden="true">*</span>` : '') +
60
+ `</div>`
61
+ : '';
62
+
63
+ const control =
64
+ `<div class="ol8-field__control">` +
65
+ (leadingIcon ? renderIcon(leadingIcon, { size: 18, className: 'ol8-field__leading-icon' }) : '') +
66
+ (prefix ? `<span class="ol8-field__affix" aria-hidden="true">${escapeText(prefix)}</span>` : '') +
67
+ `<input class="ol8-field__input" id="${escapeAttr(id)}" type="${escapeAttr(type)}"` +
68
+ (name ? ` name="${escapeAttr(name)}"` : '') +
69
+ (value !== undefined ? ` value="${escapeAttr(value)}"` : '') +
70
+ (placeholder ? ` placeholder="${escapeAttr(placeholder)}"` : '') +
71
+ (required ? ' required' : '') +
72
+ (disabled ? ' disabled' : '') +
73
+ (readOnly ? ' readonly' : '') +
74
+ (invalid ? ' aria-invalid="true"' : '') +
75
+ (invalid && messageId ? ` aria-errormessage="${messageId}"` : '') +
76
+ // A native maxlength counts UTF-16 units, which would cut an emoji in half.
77
+ // The limit is enforced here instead, in graphemes, per the contract.
78
+ (characterLimit !== undefined ? ` data-ol8-limit="${characterLimit}" data-ol8-limit-behavior="${characterLimitBehavior}"` : '') +
79
+ (describedBy ? ` aria-describedby="${escapeAttr(describedBy)}"` : '') +
80
+ (showLabel ? '' : ` aria-label="${escapeAttr(label)}"`) +
81
+ `>` +
82
+ (suffix ? `<span class="ol8-field__affix" aria-hidden="true">${escapeText(suffix)}</span>` : '') +
83
+ (trailingAction
84
+ ? `<button class="ol8-field__trailing-action" type="button"` +
85
+ (disabled ? ' disabled' : '') +
86
+ ` aria-label="${escapeAttr(trailingAction.label)}">` +
87
+ renderIcon(trailingAction.icon, { size: 18 }) +
88
+ `</button>`
89
+ : '') +
90
+ `</div>`;
91
+
92
+ // Instruction is its own full width row. Message and counter share the next
93
+ // one, counter at the logical inline end, wrapping below at narrow widths.
94
+ const supporting = (message || counterId)
95
+ ? `<div class="ol8-field__supporting">` +
96
+ (message
97
+ ? renderFormMessage(message, { tone: validationStatus === 'pending' ? 'pending' : messageTone, icon: messageIcon, id: messageId })
98
+ : '<span class="ol8-field__supporting-spacer"></span>') +
99
+ (counterId
100
+ ? `<span class="ol8-field__counter" id="${counterId}">${countGraphemes(value)} / ${characterLimit}</span>`
101
+ : '') +
102
+ `</div>`
103
+ : '';
104
+
105
+ const attrs = [
106
+ `class="ol8-field${className ? ` ${className}` : ''}"`,
107
+ `data-ol8-size="${size}"`,
108
+ `data-ol8-appearance="${appearance}"`,
109
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
110
+ disabled ? 'data-ol8-disabled="true"' : '',
111
+ readOnly ? 'data-ol8-readonly="true"' : '',
112
+ invalid ? 'data-ol8-invalid="true"' : '',
113
+ validationStatus !== 'idle' ? `data-ol8-validation="${validationStatus}"` : '',
114
+ ].filter(Boolean).join(' ');
115
+
116
+ return `<div ${attrs}>` +
117
+ labelRow +
118
+ `<div class="ol8-field__control-stack">${control}</div>` +
119
+ (instruction ? `<div class="ol8-field__instruction" id="${instructionId}">${escapeText(instruction)}</div>` : '') +
120
+ supporting +
121
+ `</div>`;
122
+ }
123
+
124
+ /**
125
+ * Enforces the runtime contract on existing `.ol8-field` markup. Idempotent.
126
+ *
127
+ * - a field must own exactly one native input, since that is what carries
128
+ * type, value, autofill, IME, undo and the platform's text services
129
+ * - the label must point at that input, or it labels nothing
130
+ * - clicking anywhere in the control frame focuses the input, except on the
131
+ * independent trailing action
132
+ * - the counter counts graphemes as the person types, and a hard limit stops
133
+ * accepting input rather than corrupting a cluster
134
+ */
135
+ export function hydrateTextFields(root = document) {
136
+ const problems = [];
137
+ for (const field of root.querySelectorAll('.ol8-field')) {
138
+ const input = field.querySelector('.ol8-field__input');
139
+ if (!input) { problems.push(field); continue; }
140
+
141
+ if (!field.dataset.ol8Size) field.dataset.ol8Size = 'comfortable';
142
+ if (!field.dataset.ol8Appearance) field.dataset.ol8Appearance = 'outline';
143
+
144
+ const label = field.querySelector('.ol8-field__label');
145
+ if (label && !label.getAttribute('for')) {
146
+ if (!input.id) input.id = nextId();
147
+ label.setAttribute('for', input.id);
148
+ }
149
+
150
+ const control = field.querySelector('.ol8-field__control');
151
+ if (control && control.dataset.ol8Wired !== 'true') {
152
+ control.dataset.ol8Wired = 'true';
153
+ control.addEventListener('mousedown', (event) => {
154
+ if (event.target === input) return;
155
+ if (event.target.closest('.ol8-field__trailing-action')) return;
156
+ event.preventDefault();
157
+ input.focus();
158
+ });
159
+ }
160
+
161
+ const limit = Number(input.dataset.ol8Limit);
162
+ if (Number.isInteger(limit) && input.dataset.ol8Counting !== 'true') {
163
+ input.dataset.ol8Counting = 'true';
164
+ const counter = field.querySelector('.ol8-field__counter');
165
+ const paint = () => {
166
+ if (input.dataset.ol8LimitBehavior === 'hard') {
167
+ const clipped = clipToGraphemes(input.value, limit);
168
+ if (clipped !== input.value) input.value = clipped;
169
+ }
170
+ if (counter) counter.textContent = `${countGraphemes(input.value)} / ${limit}`;
171
+ };
172
+ input.addEventListener('input', paint);
173
+ paint();
174
+ }
175
+ }
176
+ if (problems.length > 0) {
177
+ console.warn(`[ol8] ${problems.length} .ol8-field element(s) contain no native input`, problems);
178
+ }
179
+ return problems.length;
180
+ }
181
+
182
+ /**
183
+ * Counts what a reader would call characters. An emoji built from several code
184
+ * points is one, which is why the contract asks for extended grapheme clusters
185
+ * rather than UTF-16 code units or bytes.
186
+ */
187
+ export function countGraphemes(value) {
188
+ return segment(value).length;
189
+ }
190
+
191
+ /** Keeps the first `limit` graphemes, so a cluster is never cut in half. */
192
+ export function clipToGraphemes(value, limit) {
193
+ const parts = segment(value);
194
+ return parts.length <= limit ? String(value ?? '') : parts.slice(0, limit).join('');
195
+ }
196
+
197
+ function segment(value) {
198
+ const text = String(value ?? '');
199
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
200
+ return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)].map(s => s.segment);
201
+ }
202
+ return [...text];
203
+ }
204
+
205
+ /** Every rule here is one the contract states outright. */
206
+ function assertContract({
207
+ label, size, appearance, material, messageTone, validationStatus,
208
+ characterLimit, characterLimitBehavior, readOnly, invalid,
209
+ }) {
210
+ if (typeof label !== 'string' || label === '') {
211
+ throw new Error('[ol8] Text Field requires a label. A placeholder is never the label.');
212
+ }
213
+ if (!OL8_TEXT_FIELD_SIZES.includes(size)) throw new Error(`[ol8] unknown text field size "${size}"`);
214
+ if (!OL8_TEXT_FIELD_APPEARANCES.includes(appearance)) throw new Error(`[ol8] unknown text field appearance "${appearance}"`);
215
+ if (!OL8_TEXT_FIELD_MATERIALS.includes(material)) throw new Error(`[ol8] unknown text field material "${material}"`);
216
+ if (!OL8_MESSAGE_TONES.includes(messageTone)) {
217
+ throw new Error(`[ol8] unknown message tone "${messageTone}". Pending is a validationStatus, not a tone.`);
218
+ }
219
+ if (!OL8_VALIDATION_STATUSES.includes(validationStatus)) {
220
+ throw new Error(`[ol8] unknown validation status "${validationStatus}"`);
221
+ }
222
+ if (!OL8_CHARACTER_LIMIT_BEHAVIORS.includes(characterLimitBehavior)) {
223
+ throw new Error(`[ol8] unknown character limit behavior "${characterLimitBehavior}"`);
224
+ }
225
+ if (characterLimit !== undefined && (!Number.isInteger(characterLimit) || characterLimit <= 0)) {
226
+ throw new Error('[ol8] characterLimit counts graphemes, so it must be a positive whole number.');
227
+ }
228
+ if (readOnly && invalid) {
229
+ throw new Error('[ol8] a read only field cannot be invalid. If a person cannot correct the value, the problem belongs to system feedback rather than to an editable field error.');
230
+ }
231
+ }
232
+
233
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
234
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1,2 @@
1
+ export { OL8_TOKEN_SIZES, OL8_TOKEN_MARKS, resolveTokenMark, renderToken } from './token.js';
2
+ export type { Ol8TokenSize, Ol8TokenMark, Ol8TokenOptions } from './token.js';
@@ -0,0 +1 @@
1
+ export { OL8_TOKEN_SIZES, OL8_TOKEN_MARKS, resolveTokenMark, renderToken } from './token.js';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Oneli8 · Token (MOLECULE)
3
+ * Figma: Select and Combobox / Molecule / Token 897:2106 (24 variants)
4
+ *
5
+ * The silhouette, sizes and colours all come from the Choice Chip stylesheet,
6
+ * because Figma's Token instantiates the Choice Chip Geometry Owner rather than
7
+ * redrawing it. Every rule here is about the one axis Token adds: the Mark.
8
+ */
9
+
10
+ /* A token is content, not a control. Only its Remove mark is interactive. */
11
+ .ol8-token.ol8-chip { cursor: default; }
12
+
13
+ /* ---- Remove ---------------------------------------------------------- */
14
+ .ol8-token__remove {
15
+ position: relative; /* anchors the expanded target below */
16
+ padding: 0;
17
+ border: 0;
18
+ background: none;
19
+ color: inherit;
20
+ cursor: pointer;
21
+ /* The ring follows the mark, not the chip: the chip is not the target. */
22
+ border-radius: var(--ol8-shape-radius-full);
23
+ }
24
+
25
+ /* The mark is drawn at 18 because that is what Figma draws, but the contract
26
+ asks for a target of at least 24 that does not overlap anything. The target
27
+ is expanded with a pseudo element so the drawn size and the reachable size
28
+ can differ without moving the label: it grows 3 into the 9 gap on one side
29
+ and into the rail inset on the other, so it still touches neither. */
30
+ .ol8-token__remove::before {
31
+ content: "";
32
+ position: absolute;
33
+ inline-size: var(--ol8-component-choice-chip-mark-target);
34
+ block-size: var(--ol8-component-choice-chip-mark-target);
35
+ transform: translate(-50%, -50%);
36
+ inset-block-start: 50%;
37
+ inset-inline-start: 50%;
38
+ }
39
+
40
+ .ol8-token__remove:focus-visible {
41
+ outline: var(--ol8-focus-ring-innerwidth) solid var(--ol8-color-focus-inner);
42
+ outline-offset: var(--ol8-focus-ring-offset);
43
+ }
44
+
45
+ /* ---- Mark size parity ------------------------------------------------
46
+ The two marks have to read as one size, and they did not. The Icon Library's
47
+ cross spans 12 x 12 in the canonical 24 space while the check spans
48
+ 10.2 x 6.9, so drawn at one frame size the cross was fifteen percent wider
49
+ and well over half again as tall, and it swamped the check beside it.
50
+
51
+ Figma does not draw it that way: its Remove mark is an Icon Frame instance at
52
+ 18, so the cross comes out at 9 x 9 there, a little narrower than the check
53
+ and balanced against it. The oversized cross was this stylesheet rendering
54
+ the library source at full size, not a decision in the design.
55
+
56
+ So the cross is scaled to the 9 span Figma draws. The stroke does not follow
57
+ it: it is a non scaling stroke, so it stays the governed 1.8 that every glyph
58
+ in the system uses, which is also what Figma keeps. */
59
+ .ol8-token__remove > svg > * {
60
+ transform: scale(0.75); /* 9 / 12, the span Figma draws */
61
+ transform-box: fill-box;
62
+ transform-origin: center;
63
+ }
@@ -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,93 @@
1
+ /**
2
+ * Oneli8 · Choice Group (ORGANISM)
3
+ * Figma: Documentation / Organism / Choice Group
4
+ * Checkbox Hierarchy 273:603 Aggregate = None | Some | All
5
+ * Radio Group 859:5180 Behavior = Safe Default | No Default |
6
+ * Horizontal | Invalid
7
+ *
8
+ * First organism in the system, and the first tier allowed to hold logic:
9
+ * the checkbox group computes its parent from its children, and the radio
10
+ * group owns group-level validation.
11
+ *
12
+ * From Figma (273:603): "Aggregate None/Some/All is computed from nested
13
+ * Checkbox molecules; MIXED IS NEVER A THIRD PREFERENCE. Runtime recursion,
14
+ * parent-child synchronization, group naming, required behavior, reset, and
15
+ * announcements live in code."
16
+ *
17
+ * So Aggregate is an OUTPUT, never an input. Nothing may author it.
18
+ *
19
+ * The two shells differ, deliberately:
20
+ * checkbox bordered, radius-container (24px), legend Title/Small
21
+ * radio unbordered, radius-standard (12px), legend Body/Medium
22
+ */
23
+
24
+ .ol8-choice-group {
25
+ box-sizing: border-box;
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: var(--ol8-spacing-stack-related);
29
+ padding: var(--ol8-spacing-inset-standard);
30
+ background: var(--ol8-color-surface-raised);
31
+ border: 0;
32
+ border-radius: var(--ol8-shape-radius-standard);
33
+ font-family: var(--ol8-font-family-functional);
34
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
35
+ min-inline-size: 0; /* fieldset defaults to min-content; this lets it shrink */
36
+ }
37
+
38
+ .ol8-choice-group--checkbox {
39
+ border: 1px solid var(--ol8-color-border-subtle);
40
+ border-radius: var(--ol8-shape-radius-container);
41
+ }
42
+
43
+ /* <legend> is taken out of flow by default, so it is laid out as a normal
44
+ flex child instead — the group label is a real legend for assistive tech. */
45
+ .ol8-choice-group__legend {
46
+ display: block;
47
+ float: none;
48
+ padding: 0;
49
+ inline-size: 100%;
50
+ color: var(--ol8-color-text-primary);
51
+ }
52
+ .ol8-choice-group--checkbox > .ol8-choice-group__legend {
53
+ font-size: var(--ol8-font-size-021);
54
+ line-height: var(--ol8-font-line-030);
55
+ font-weight: var(--ol8-font-weight-semibold);
56
+ }
57
+ .ol8-choice-group--radio > .ol8-choice-group__legend {
58
+ font-size: var(--ol8-font-size-016);
59
+ line-height: var(--ol8-font-line-024);
60
+ font-weight: var(--ol8-font-weight-regular);
61
+ }
62
+
63
+ .ol8-choice-group__instruction {
64
+ font-size: var(--ol8-font-size-012);
65
+ line-height: var(--ol8-font-line-018);
66
+ letter-spacing: 0.12px;
67
+ color: var(--ol8-color-text-secondary);
68
+ }
69
+
70
+ .ol8-choice-group__options { display: flex; flex-direction: column; }
71
+ .ol8-choice-group[data-ol8-orientation="horizontal"] > .ol8-choice-group__options {
72
+ flex-direction: row;
73
+ gap: var(--ol8-spacing-stack-related);
74
+ flex-wrap: wrap;
75
+ }
76
+
77
+ /* Children of a hierarchy sit under their parent, indented one 24px step. */
78
+ .ol8-choice-group__children {
79
+ display: flex;
80
+ flex-direction: column;
81
+ padding-inline-start: var(--ol8-dimension-scale-024);
82
+ }
83
+
84
+ /* Group-level validation message. */
85
+ .ol8-choice-group__message {
86
+ font-size: var(--ol8-font-size-012);
87
+ line-height: var(--ol8-font-line-018);
88
+ letter-spacing: 0.12px;
89
+ color: var(--ol8-color-text-secondary);
90
+ }
91
+ .ol8-choice-group[data-ol8-invalid="true"] > .ol8-choice-group__message {
92
+ color: var(--ol8-color-feedbackcritical-inlinecontent);
93
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Choice Group (ORGANISM) — headless behavior. Aggregate None/Some/All is
3
+ * computed from nested checkboxes; Mixed is never a third preference. The radio
4
+ * group needs almost nothing: native radios already give roving focus, single
5
+ * selection and form submission.
6
+ */
7
+ import type { Ol8ChoiceSize } from '../../molecules/choice-item/choice-item.js';
8
+
9
+ export type Ol8Aggregate = 'none' | 'some' | 'all';
10
+ export declare const OL8_AGGREGATE: readonly Ol8Aggregate[];
11
+
12
+ /** Disabled children are excluded from the tally. */
13
+ export declare function computeAggregate(
14
+ children: Iterable<HTMLInputElement> | ArrayLike<HTMLInputElement>,
15
+ ): Ol8Aggregate;
16
+
17
+ interface Ol8ChoiceGroupShell {
18
+ legend: string;
19
+ instruction?: string;
20
+ message?: string;
21
+ invalid?: boolean;
22
+ disabled?: boolean;
23
+ size?: Ol8ChoiceSize;
24
+ material?: 'regular' | 'gem';
25
+ id?: string;
26
+ className?: string;
27
+ }
28
+
29
+ export interface Ol8RadioOption {
30
+ label: string;
31
+ value: string;
32
+ description?: string;
33
+ disabled?: boolean;
34
+ }
35
+
36
+ export interface Ol8RadioGroupConfig extends Ol8ChoiceGroupShell {
37
+ /** Required — it is what makes the radios a group. */
38
+ name: string;
39
+ options: Ol8RadioOption[];
40
+ /** The selected option's value. Omit for no default. */
41
+ value?: string;
42
+ orientation?: 'vertical' | 'horizontal';
43
+ required?: boolean;
44
+ }
45
+
46
+ export interface Ol8CheckboxOption {
47
+ label: string;
48
+ value: string;
49
+ /** Checkboxes are independent, so each carries its own state. */
50
+ checked?: boolean;
51
+ description?: string;
52
+ disabled?: boolean;
53
+ }
54
+
55
+ export interface Ol8CheckboxGroupConfig extends Ol8ChoiceGroupShell {
56
+ /** Required — the hierarchy's parent row. */
57
+ parentLabel: string;
58
+ options: Ol8CheckboxOption[];
59
+ /** Shared input name for the children. Optional: checkboxes need no group name. */
60
+ name?: string;
61
+ }
62
+
63
+ /**
64
+ * Native semantics do the work; this only builds the shell.
65
+ * @throws if legend, name or options are missing.
66
+ */
67
+ export declare function renderRadioGroup(config: Ol8RadioGroupConfig): string;
68
+
69
+ /**
70
+ * Checkbox hierarchy: one parent whose state is computed from its children. The
71
+ * parent renders unchecked; hydration derives its real state.
72
+ * @throws if legend, parentLabel or options are missing.
73
+ */
74
+ export declare function renderCheckboxGroup(config: Ol8CheckboxGroupConfig): string;
75
+
76
+ /**
77
+ * Wires every checkbox hierarchy under `root`. Idempotent.
78
+ * Radio groups need no wiring — the browser already owns their behaviour.
79
+ */
80
+ export declare function hydrateChoiceGroups(root?: ParentNode): void;