@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,5 @@
1
+ export {
2
+ renderTextField, hydrateTextFields, countGraphemes, clipToGraphemes,
3
+ OL8_TEXT_FIELD_SIZES, OL8_TEXT_FIELD_APPEARANCES, OL8_TEXT_FIELD_MATERIALS,
4
+ OL8_MESSAGE_TONES, OL8_VALIDATION_STATUSES, OL8_CHARACTER_LIMIT_BEHAVIORS,
5
+ } from './text-field.js';
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Oneli8 · Text Field (MOLECULE)
3
+ * Figma: Text Field / Outline 182:33, Text Field / Filled 186:53,
4
+ * Text Field / Outline / Gem 211:36, Text Field / Filled / Gem 215:226,
5
+ * Text Field / Content 175:2, Form Message 177:13.
6
+ *
7
+ * ANATOMY, read from Figma rather than assumed:
8
+ * root vertical, gap Component / Text Field / Gap / Supporting
9
+ * · Label Row horizontal, gap Component / Text Field / Gap / Label
10
+ * · Label twelve over eighteen, Semibold, Tracking Open, Text Primary
11
+ * · Requirement a star in Feedback Critical Content, Regular weight
12
+ * · Control Stack fixed at Component / Text Field / Height / <size>
13
+ * · Focus Edge shown only while focused, Focus Ring Inner Width
14
+ * · Control gap Inline; inset, radius and boundary per size; surface and border
15
+ * per appearance and condition
16
+ * · Leading Icon Size / Icon / Small
17
+ * · Prefix the content size, Text Secondary
18
+ * · Input fills the row
19
+ * · Suffix the content size, Text Secondary
20
+ * · Trailing twenty four
21
+ * · Instruction twelve over eighteen, Text Secondary
22
+ * · Supporting Region horizontal, space between, gap Message Counter
23
+ * · Form Message its own molecule
24
+ * · Counter twelve over eighteen, Text Secondary
25
+ *
26
+ * CONDITION IS NOT A CLASS. Figma's five Conditions are review evidence. Here
27
+ * they are :hover, [aria-invalid], [readonly] and :disabled on the real input,
28
+ * exactly as the Figma description asks.
29
+ */
30
+
31
+ .ol8-field {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: var(--ol8-component-textfield-gap-supporting);
35
+ inline-size: 100%;
36
+
37
+ --_surface: var(--ol8-color-surface-default);
38
+ --_border: var(--ol8-color-border-default);
39
+ --_label: var(--ol8-color-text-primary);
40
+ --_ink: var(--ol8-color-text-primary);
41
+ --_muted: var(--ol8-color-text-secondary);
42
+ }
43
+
44
+ /* ---- Label ------------------------------------------------------------ */
45
+ .ol8-field__label-row {
46
+ display: flex;
47
+ align-items: flex-start;
48
+ gap: var(--ol8-component-textfield-gap-requirement);
49
+ }
50
+ .ol8-field__label {
51
+ color: var(--_label);
52
+ font-family: var(--ol8-font-family-functional);
53
+ font-size: var(--ol8-font-size-012);
54
+ font-weight: var(--ol8-font-weight-semibold);
55
+ line-height: var(--ol8-font-line-018);
56
+ letter-spacing: var(--ol8-font-tracking-open);
57
+ }
58
+ .ol8-field__requirement {
59
+ color: var(--ol8-color-feedbackcritical-content);
60
+ font-family: var(--ol8-font-family-functional);
61
+ font-size: var(--ol8-font-size-012);
62
+ font-weight: var(--ol8-font-weight-regular);
63
+ line-height: var(--ol8-font-line-018);
64
+ }
65
+
66
+ /* ---- Control ---------------------------------------------------------- */
67
+ .ol8-field__control-stack {
68
+ position: relative;
69
+ display: flex;
70
+ block-size: var(--_height);
71
+ }
72
+
73
+ /* The contract asks a coarse pointer adapter to expand the activation region
74
+ for Compact and Standard, which are shorter than the protected minimum,
75
+ without overlapping a neighbouring field. That belongs to a platform adapter:
76
+ an overlay drawn here would sit over the value and take the caret away from
77
+ the person, which is worse than the shortfall it fixes. Not implemented. */
78
+
79
+ .ol8-field__control {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: var(--ol8-component-textfield-gap-inline);
83
+ inline-size: 100%;
84
+ block-size: var(--_height);
85
+ padding-inline: var(--_inset);
86
+ border: var(--ol8-component-textfield-boundary) solid var(--_border);
87
+ border-radius: var(--_radius);
88
+ background: var(--_surface);
89
+ transition-property: border-color, background-color;
90
+ transition-duration: var(--ol8-motion-interaction-hover-duration);
91
+ transition-timing-function: var(--ol8-motion-interaction-hover-timing-function);
92
+ }
93
+
94
+ .ol8-field__leading-icon { flex: none; color: var(--ol8-color-icon-secondary); }
95
+
96
+ .ol8-field__affix {
97
+ flex: none;
98
+ color: var(--_muted);
99
+ font-family: var(--ol8-font-family-functional);
100
+ font-size: var(--_content-size);
101
+ font-weight: var(--ol8-font-weight-regular);
102
+ line-height: var(--_content-line);
103
+ }
104
+
105
+ /* The input carries the value, so it takes the primary ink and hands the
106
+ secondary ink to its placeholder. That is exactly the Content molecule's
107
+ Value and Placeholder types. */
108
+ .ol8-field__input {
109
+ flex: 1 1 auto;
110
+ min-inline-size: 0;
111
+ border: 0;
112
+ padding: 0;
113
+ background: none;
114
+ color: var(--_ink);
115
+ font-family: var(--ol8-font-family-functional);
116
+ font-size: var(--_content-size);
117
+ font-weight: var(--ol8-font-weight-regular);
118
+ line-height: var(--_content-line);
119
+ }
120
+ .ol8-field__input::placeholder { color: var(--_muted); opacity: 1; }
121
+ .ol8-field__input:focus { outline: none; }
122
+
123
+ .ol8-field__trailing-action {
124
+ flex: none;
125
+ display: inline-flex;
126
+ border: 0;
127
+ padding: 0;
128
+ background: none;
129
+ color: var(--ol8-color-icon-secondary);
130
+ cursor: pointer;
131
+ }
132
+ .ol8-field__trailing-action:disabled { color: var(--ol8-color-icon-disabled); cursor: not-allowed; }
133
+
134
+ /* ---- Size ------------------------------------------------------------- */
135
+ .ol8-field[data-ol8-size="compact"] {
136
+ --_height: var(--ol8-component-textfield-height-compact);
137
+ --_inset: var(--ol8-component-textfield-inset-compact);
138
+ --_radius: var(--ol8-component-textfield-radius-compact);
139
+ --_content-size: var(--ol8-font-size-012);
140
+ --_content-line: var(--ol8-font-line-018);
141
+ }
142
+ .ol8-field[data-ol8-size="standard"] {
143
+ --_height: var(--ol8-component-textfield-height-standard);
144
+ --_inset: var(--ol8-component-textfield-inset-standard);
145
+ --_radius: var(--ol8-component-textfield-radius-standard);
146
+ --_content-size: var(--ol8-font-size-016);
147
+ --_content-line: var(--ol8-font-line-024);
148
+ }
149
+ /* Comfortable grows its box and keeps the standard reading size, which is what
150
+ Figma's Content set does at this step. */
151
+ .ol8-field[data-ol8-size="comfortable"] {
152
+ --_height: var(--ol8-component-textfield-height-comfortable);
153
+ --_inset: var(--ol8-component-textfield-inset-comfortable);
154
+ --_radius: var(--ol8-component-textfield-radius-comfortable);
155
+ --_content-size: var(--ol8-font-size-016);
156
+ --_content-line: var(--ol8-font-line-024);
157
+ }
158
+ .ol8-field[data-ol8-size="large"] {
159
+ --_height: var(--ol8-component-textfield-height-large);
160
+ --_inset: var(--ol8-component-textfield-inset-large);
161
+ --_radius: var(--ol8-component-textfield-radius-large);
162
+ --_content-size: var(--ol8-font-size-021);
163
+ --_content-line: var(--ol8-font-line-030);
164
+ }
165
+
166
+ /* ---- Appearance ------------------------------------------------------- */
167
+ .ol8-field[data-ol8-appearance="filled"] {
168
+ --_surface: var(--ol8-color-surface-sunken);
169
+ --_border: var(--ol8-color-border-subtle);
170
+ }
171
+
172
+ /* ---- Condition, carried by the real input ----------------------------- */
173
+ /* Base condition precedence is Disabled, then Read only, then Invalid, then
174
+ Default. Hover is an overlay that renders only above Default or Invalid, and
175
+ only for a fine pointer. */
176
+ @media (hover: hover) and (pointer: fine) {
177
+ .ol8-field:hover:not([data-ol8-disabled]):not([data-ol8-readonly]) .ol8-field__control {
178
+ --_border: var(--ol8-color-border-strong);
179
+ }
180
+ }
181
+
182
+ .ol8-field[data-ol8-readonly] .ol8-field__control { --_border: var(--ol8-color-border-subtle); }
183
+
184
+ /* Focus never suppresses Invalid; the focus edge is added on top of it. */
185
+ .ol8-field[data-ol8-invalid] .ol8-field__control,
186
+ .ol8-field:has(.ol8-field__input[aria-invalid="true"]) .ol8-field__control {
187
+ --_border: var(--ol8-color-feedbackcritical-inlinecontent);
188
+ }
189
+
190
+ .ol8-field[data-ol8-disabled] {
191
+ --_label: var(--ol8-color-text-disabled);
192
+ --_muted: var(--ol8-color-text-disabled);
193
+ --_ink: var(--ol8-color-text-disabled);
194
+ }
195
+ .ol8-field[data-ol8-disabled] .ol8-field__control {
196
+ --_surface: var(--ol8-color-surface-disabled);
197
+ --_border: var(--ol8-color-border-disabled);
198
+ }
199
+ .ol8-field[data-ol8-disabled] .ol8-field__input { cursor: not-allowed; }
200
+ .ol8-field[data-ol8-disabled] .ol8-field__leading-icon { color: var(--ol8-color-icon-disabled); }
201
+
202
+ /* ---- Focus -----------------------------------------------------------
203
+ Figma draws a Focus Edge sibling that is shown only while focused, at the
204
+ Focus Ring Inner Width in the outer focus colour. The stack owns it so the
205
+ edge cannot change the control's own metrics. */
206
+ .ol8-field__control-stack::after {
207
+ content: "";
208
+ position: absolute;
209
+ inset: 0;
210
+ border-radius: var(--_radius);
211
+ box-shadow: inset 0 0 0 var(--ol8-focus-ring-innerwidth) var(--ol8-color-focus-outer);
212
+ opacity: 0;
213
+ pointer-events: none;
214
+ transition-property: opacity;
215
+ transition-duration: var(--ol8-motion-interaction-focus-duration);
216
+ transition-timing-function: var(--ol8-motion-interaction-focus-timing-function);
217
+ }
218
+ .ol8-field:has(.ol8-field__input:focus-visible) .ol8-field__control-stack::after { opacity: 1; }
219
+
220
+ @media (prefers-reduced-motion: reduce) {
221
+ .ol8-field__control,
222
+ .ol8-field__control-stack::after { transition-duration: var(--ol8-motion-duration-instant); }
223
+ }
224
+
225
+ /* ---- Instruction, message and counter --------------------------------- */
226
+ .ol8-field__instruction {
227
+ color: var(--_muted);
228
+ font-family: var(--ol8-font-family-functional);
229
+ font-size: var(--ol8-font-size-012);
230
+ font-weight: var(--ol8-font-weight-regular);
231
+ line-height: var(--ol8-font-line-018);
232
+ letter-spacing: var(--ol8-font-tracking-open);
233
+ }
234
+
235
+ /* The message and the counter share one row, counter at the logical inline end.
236
+ At a narrow width or with enlarged text the counter wraps below the message in
237
+ source order, and the row gap becomes the tighter status step. The counter is
238
+ never hidden merely because a message appeared. */
239
+ .ol8-field__supporting {
240
+ display: flex;
241
+ flex-wrap: wrap;
242
+ align-items: baseline;
243
+ justify-content: space-between;
244
+ column-gap: var(--ol8-component-textfield-gap-messagecounter);
245
+ row-gap: var(--ol8-component-textfield-gap-status);
246
+ }
247
+ .ol8-field__supporting-spacer { flex: 1 1 auto; }
248
+
249
+ /* Counter is information, not validation, so it never takes a feedback colour. */
250
+ .ol8-field__counter {
251
+ flex: none;
252
+ color: var(--_muted);
253
+ font-family: var(--ol8-font-family-functional);
254
+ font-size: var(--ol8-font-size-012);
255
+ font-weight: var(--ol8-font-weight-regular);
256
+ line-height: var(--ol8-font-line-018);
257
+ letter-spacing: var(--ol8-font-tracking-open);
258
+ font-variant-numeric: tabular-nums;
259
+ }
260
+
261
+ @media (forced-colors: active) {
262
+ .ol8-field__control { border-color: ButtonBorder; }
263
+ .ol8-field:has(.ol8-field__input:focus-visible) .ol8-field__control-stack::after {
264
+ box-shadow: inset 0 0 0 var(--ol8-focus-ring-innerwidth) Highlight;
265
+ }
266
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Text Field (MOLECULE). Figma 182:33 Outline, 186:53 Filled, 211:36 and
3
+ * 215:226 Gem.
4
+ *
5
+ * Figma's five Conditions are review evidence, never options. Default, Hover,
6
+ * Invalid, Read-only and Disabled are :hover, aria-invalid, readOnly and
7
+ * disabled on the native input. Gem changes token bound material only.
8
+ */
9
+ export type Ol8TextFieldSize = 'compact' | 'standard' | 'comfortable' | 'large';
10
+ export type Ol8TextFieldAppearance = 'outline' | 'filled';
11
+ export type Ol8TextFieldMaterial = 'regular' | 'gem';
12
+ /** Pending is a validation process status, not a fifth message tone. */
13
+ export type Ol8MessageTone = 'critical' | 'caution' | 'positive' | 'informative';
14
+ export type Ol8ValidationStatus = 'idle' | 'pending' | 'resolved';
15
+ export type Ol8CharacterLimitBehavior = 'soft' | 'hard';
16
+
17
+ export declare const OL8_TEXT_FIELD_SIZES: readonly Ol8TextFieldSize[];
18
+ export declare const OL8_TEXT_FIELD_APPEARANCES: readonly Ol8TextFieldAppearance[];
19
+ export declare const OL8_TEXT_FIELD_MATERIALS: readonly Ol8TextFieldMaterial[];
20
+ export declare const OL8_MESSAGE_TONES: readonly Ol8MessageTone[];
21
+ export declare const OL8_VALIDATION_STATUSES: readonly Ol8ValidationStatus[];
22
+ export declare const OL8_CHARACTER_LIMIT_BEHAVIORS: readonly Ol8CharacterLimitBehavior[];
23
+
24
+ export interface Ol8TextFieldOptions {
25
+ /** Required. A field nobody can name is a field nobody can fill. */
26
+ label: string;
27
+ name?: string;
28
+ id?: string;
29
+ type?: string;
30
+ value?: string;
31
+ placeholder?: string;
32
+ size?: Ol8TextFieldSize;
33
+ appearance?: Ol8TextFieldAppearance;
34
+ material?: Ol8TextFieldMaterial;
35
+ /** False keeps the label as the accessible name without showing it, which needs a documented exception. */
36
+ showLabel?: boolean;
37
+ required?: boolean;
38
+ disabled?: boolean;
39
+ readOnly?: boolean;
40
+ /** Correction is required. Never set without also exposing a Critical message. */
41
+ invalid?: boolean;
42
+ leadingIcon?: string;
43
+ prefix?: string;
44
+ suffix?: string;
45
+ trailingAction?: { icon: string; label: string };
46
+ instruction?: string;
47
+ message?: string;
48
+ messageTone?: Ol8MessageTone;
49
+ messageIcon?: boolean;
50
+ /** Pending shows the loader; it never freezes typing. */
51
+ validationStatus?: Ol8ValidationStatus;
52
+ /** Counted in graphemes, so an emoji counts as one. */
53
+ characterLimit?: number;
54
+ /** Soft lets a person overshoot and then correct. Hard is for real technical limits. */
55
+ characterLimitBehavior?: Ol8CharacterLimitBehavior;
56
+ className?: string;
57
+ }
58
+
59
+ /** @throws without a label, on an unknown size, appearance, material, tone or status, or when a read only field is marked invalid. */
60
+ export declare function renderTextField(options: Ol8TextFieldOptions): string;
61
+
62
+ /** Enforces the runtime contract on existing `.ol8-field` markup. Returns the count of fields with no native input. */
63
+ export declare function hydrateTextFields(root?: ParentNode): number;
64
+
65
+ /** Counts what a reader would call characters. */
66
+ export declare function countGraphemes(value: unknown): number;
67
+
68
+ /** Keeps the first `limit` graphemes, so a cluster is never cut in half. */
69
+ export declare function clipToGraphemes(value: unknown, limit: number): string;
@@ -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
+ }