@oneli8/core 1.0.0-beta.5 → 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.
- package/README.md +19 -7
- package/ai-context.json +168 -1
- package/package.json +18 -3
- package/src/atoms/index.d.ts +1 -0
- package/src/atoms/index.js +1 -0
- package/src/atoms/navigation-badge/index.d.ts +1 -0
- package/src/atoms/navigation-badge/index.js +1 -0
- package/src/atoms/navigation-badge/navigation-badge.css +35 -0
- package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
- package/src/atoms/navigation-badge/navigation-badge.js +21 -0
- package/src/atoms/selection-indicator/selection-indicator.css +13 -4
- package/src/materials/gem/gem.css +52 -5
- package/src/molecules/choice-chip/choice-chip.css +214 -0
- package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
- package/src/molecules/choice-chip/choice-chip.js +170 -0
- package/src/molecules/choice-chip/index.d.ts +2 -0
- package/src/molecules/choice-chip/index.js +10 -0
- package/src/molecules/form-message/form-message.css +46 -0
- package/src/molecules/form-message/form-message.d.ts +23 -0
- package/src/molecules/form-message/form-message.js +73 -0
- package/src/molecules/form-message/index.d.ts +1 -0
- package/src/molecules/form-message/index.js +1 -0
- package/src/molecules/index.d.ts +6 -0
- package/src/molecules/index.js +6 -0
- package/src/molecules/link/index.d.ts +1 -0
- package/src/molecules/link/index.js +1 -0
- package/src/molecules/link/link.css +114 -0
- package/src/molecules/link/link.d.ts +27 -0
- package/src/molecules/link/link.js +76 -0
- package/src/molecules/navigation-item/index.js +1 -0
- package/src/molecules/navigation-item/navigation-item.css +153 -0
- package/src/molecules/navigation-item/navigation-item.js +95 -0
- package/src/molecules/select/index.js +1 -0
- package/src/molecules/select/select.css +31 -0
- package/src/molecules/select/select.js +127 -0
- package/src/molecules/selection-option/selection-option.js +4 -4
- package/src/molecules/text-field/index.d.ts +1 -0
- package/src/molecules/text-field/index.js +5 -0
- package/src/molecules/text-field/text-field.css +266 -0
- package/src/molecules/text-field/text-field.d.ts +69 -0
- package/src/molecules/text-field/text-field.js +234 -0
- package/src/molecules/token/index.d.ts +2 -0
- package/src/molecules/token/index.js +1 -0
- package/src/molecules/token/token.css +63 -0
- package/src/molecules/token/token.d.ts +35 -0
- package/src/molecules/token/token.js +83 -0
- package/src/organisms/choice-picker/choice-picker.css +98 -0
- package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
- package/src/organisms/choice-picker/choice-picker.js +201 -0
- package/src/organisms/choice-picker/index.d.ts +1 -0
- package/src/organisms/choice-picker/index.js +1 -0
- package/src/organisms/combobox/combobox.css +40 -0
- package/src/organisms/combobox/combobox.js +309 -0
- package/src/organisms/combobox/index.js +5 -0
- package/src/organisms/index.d.ts +7 -0
- package/src/organisms/index.js +7 -0
- package/src/organisms/multi-select-field/index.d.ts +1 -0
- package/src/organisms/multi-select-field/index.js +1 -0
- package/src/organisms/multi-select-field/multi-select-field.css +76 -0
- package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
- package/src/organisms/multi-select-field/multi-select-field.js +317 -0
- package/src/organisms/navigation-keys.js +58 -0
- package/src/organisms/segmented-control/index.d.ts +1 -0
- package/src/organisms/segmented-control/index.js +1 -0
- package/src/organisms/segmented-control/segmented-control.css +113 -0
- package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
- package/src/organisms/segmented-control/segmented-control.js +163 -0
- package/src/organisms/selection-popup/index.js +1 -0
- package/src/organisms/selection-popup/selection-popup.css +90 -0
- package/src/organisms/selection-popup/selection-popup.js +100 -0
- package/src/organisms/tab-bar/index.d.ts +1 -0
- package/src/organisms/tab-bar/index.js +1 -0
- package/src/organisms/tab-bar/tab-bar.css +45 -0
- package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
- package/src/organisms/tab-bar/tab-bar.js +104 -0
- package/src/organisms/tabs/index.d.ts +1 -0
- package/src/organisms/tabs/index.js +1 -0
- package/src/organisms/tabs/tabs.css +53 -0
- package/src/organisms/tabs/tabs.d.ts +48 -0
- package/src/organisms/tabs/tabs.js +152 -0
- package/styles.css +18 -2
|
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
234
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -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
|
+
}
|