@oneli8/core 1.0.0-beta.5 → 1.0.0-beta.7
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 +170 -3
- 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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Choice Chip (MOLECULE) — the Soft Hexagon
|
|
3
|
+
* Figma: Select and Combobox / Choice Chip / Soft Hexagon 636:847 (8 variants)
|
|
4
|
+
*
|
|
5
|
+
* GEOMETRY, read from all four sizes in Figma:
|
|
6
|
+
* size target visible rail inset gap label
|
|
7
|
+
* compact 48 30 9 9 12 / 18
|
|
8
|
+
* standard 48 36 12 9 12 / 18
|
|
9
|
+
* comfortable 48 42 15 9 16 / 24
|
|
10
|
+
* large 60 42 15 9 16 / 24
|
|
11
|
+
* The terminal is a fixed 12 at every size and never scales with the label,
|
|
12
|
+
* so peer chips of different lengths keep one shoulder depth and side angle.
|
|
13
|
+
*
|
|
14
|
+
* The visible silhouette sits inside a taller protected target, which is why
|
|
15
|
+
* `min-block-size` is the target and the rail carries the visible height.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
.ol8-chip {
|
|
19
|
+
--_height: var(--ol8-component-choice-chip-height-standard);
|
|
20
|
+
--_target: var(--ol8-component-choice-chip-target-standard);
|
|
21
|
+
--_inset: var(--ol8-component-choice-chip-rail-inset-standard);
|
|
22
|
+
--_label-size: var(--ol8-font-size-012);
|
|
23
|
+
--_label-line: var(--ol8-font-line-018);
|
|
24
|
+
|
|
25
|
+
/* Inactive is the quiet Neutral treatment; Selected is Primary Soft. */
|
|
26
|
+
--_surface: var(--ol8-color-surface-sunken);
|
|
27
|
+
--_edge: var(--ol8-color-border-subtle);
|
|
28
|
+
--_ink: var(--ol8-color-text-primary);
|
|
29
|
+
|
|
30
|
+
/* The ring is painted at every state and is simply invisible until focus,
|
|
31
|
+
so focus never changes the geometry, only the colour. */
|
|
32
|
+
--_focus-outer: transparent;
|
|
33
|
+
--_focus-inner: transparent;
|
|
34
|
+
|
|
35
|
+
box-sizing: border-box;
|
|
36
|
+
position: relative;
|
|
37
|
+
display: inline-flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
min-block-size: var(--_target);
|
|
40
|
+
font-family: var(--ol8-font-family-functional);
|
|
41
|
+
font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
|
|
42
|
+
color: var(--_ink);
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
}
|
|
45
|
+
.ol8-chip[data-ol8-availability="disabled"] { cursor: not-allowed; }
|
|
46
|
+
|
|
47
|
+
/* ---- Size ------------------------------------------------------------ */
|
|
48
|
+
.ol8-chip[data-ol8-size="compact"] {
|
|
49
|
+
--_height: var(--ol8-component-choice-chip-height-compact);
|
|
50
|
+
--_inset: var(--ol8-component-choice-chip-rail-inset-compact);
|
|
51
|
+
}
|
|
52
|
+
.ol8-chip[data-ol8-size="standard"] {
|
|
53
|
+
--_height: var(--ol8-component-choice-chip-height-standard);
|
|
54
|
+
--_inset: var(--ol8-component-choice-chip-rail-inset-standard);
|
|
55
|
+
}
|
|
56
|
+
.ol8-chip[data-ol8-size="comfortable"] {
|
|
57
|
+
--_height: var(--ol8-component-choice-chip-height-comfortable);
|
|
58
|
+
--_inset: var(--ol8-component-choice-chip-rail-inset-comfortable);
|
|
59
|
+
--_label-size: var(--ol8-font-size-016);
|
|
60
|
+
--_label-line: var(--ol8-font-line-024);
|
|
61
|
+
}
|
|
62
|
+
.ol8-chip[data-ol8-size="large"] {
|
|
63
|
+
--_height: var(--ol8-component-choice-chip-height-large);
|
|
64
|
+
--_target: var(--ol8-component-choice-chip-target-large);
|
|
65
|
+
--_inset: var(--ol8-component-choice-chip-rail-inset-large);
|
|
66
|
+
--_label-size: var(--ol8-font-size-016);
|
|
67
|
+
--_label-line: var(--ol8-font-line-024);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ---- Selection ------------------------------------------------------- */
|
|
71
|
+
/* Never colour alone: the native checked state carries the same fact, and the
|
|
72
|
+
selected edge is a stronger contrast than the inactive one. */
|
|
73
|
+
.ol8-chip[data-ol8-selection="selected"] {
|
|
74
|
+
--_surface: var(--ol8-color-selection-surface);
|
|
75
|
+
--_edge: var(--ol8-color-primary-360);
|
|
76
|
+
--_ink: var(--ol8-color-selection-content);
|
|
77
|
+
}
|
|
78
|
+
.ol8-chip[data-ol8-availability="disabled"] {
|
|
79
|
+
--_surface: var(--ol8-color-surface-disabled);
|
|
80
|
+
--_edge: var(--ol8-color-border-disabled);
|
|
81
|
+
--_ink: var(--ol8-color-text-disabled);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ol8-chip__input {
|
|
85
|
+
position: absolute;
|
|
86
|
+
inline-size: 1px;
|
|
87
|
+
block-size: 1px;
|
|
88
|
+
opacity: 0;
|
|
89
|
+
pointer-events: none;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ---- Terminals ------------------------------------------------------- */
|
|
93
|
+
/* One viewBox serves every height: Figma's terminal paths share identical X
|
|
94
|
+
values at 30, 36 and 42, and Y values that are the same fractions of the
|
|
95
|
+
height, so stretching Y alone is exact rather than approximate. */
|
|
96
|
+
.ol8-chip__terminal {
|
|
97
|
+
flex: none;
|
|
98
|
+
inline-size: var(--ol8-component-choice-chip-terminal);
|
|
99
|
+
block-size: var(--_height);
|
|
100
|
+
/* The point reaches 0.15 beyond the box, as it does in Figma, and the focus
|
|
101
|
+
ring is painted outside the silhouette. Neither may be clipped. */
|
|
102
|
+
overflow: visible;
|
|
103
|
+
}
|
|
104
|
+
/* The trailing terminal is the leading one mirrored, so they cannot drift. */
|
|
105
|
+
.ol8-chip__terminal--trailing { transform: scaleX(-1); }
|
|
106
|
+
|
|
107
|
+
.ol8-chip__fill { fill: var(--_surface); }
|
|
108
|
+
|
|
109
|
+
/* Double width, clipped to the fill: the outer half is cut away and what
|
|
110
|
+
remains is an inside edge of exactly one border width, matching the rail's
|
|
111
|
+
own inside border. A centred stroke would stand 1px proud of the rail and
|
|
112
|
+
show a step at the shoulder. */
|
|
113
|
+
.ol8-chip__edge {
|
|
114
|
+
fill: none;
|
|
115
|
+
stroke: var(--_edge);
|
|
116
|
+
stroke-width: calc(var(--ol8-border-width-standard) * 2);
|
|
117
|
+
vector-effect: non-scaling-stroke;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Both rings are painted before the fill and at double width, so the inner
|
|
121
|
+
half of each lands under the fill and only the outer half shows. This is how
|
|
122
|
+
the ring follows the hexagon instead of boxing it. */
|
|
123
|
+
.ol8-chip__focus-outer,
|
|
124
|
+
.ol8-chip__focus-inner { fill: none; vector-effect: non-scaling-stroke; }
|
|
125
|
+
.ol8-chip__focus-outer {
|
|
126
|
+
stroke: var(--_focus-outer);
|
|
127
|
+
stroke-width: calc((var(--ol8-focus-ring-outerwidth) + var(--ol8-focus-ring-innerwidth)) * 2);
|
|
128
|
+
}
|
|
129
|
+
.ol8-chip__focus-inner {
|
|
130
|
+
stroke: var(--_focus-inner);
|
|
131
|
+
stroke-width: calc(var(--ol8-focus-ring-innerwidth) * 2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.ol8-chip:has(> .ol8-chip__input:focus-visible) {
|
|
135
|
+
--_focus-outer: var(--ol8-color-focus-outer);
|
|
136
|
+
--_focus-inner: var(--ol8-color-focus-inner);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ---- Centre rail ----------------------------------------------------- */
|
|
140
|
+
/* Only the rail grows with the label. It owns the top and bottom of the
|
|
141
|
+
perimeter; the terminals own the angled ends. */
|
|
142
|
+
.ol8-chip__rail {
|
|
143
|
+
box-sizing: border-box;
|
|
144
|
+
position: relative;
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
gap: var(--ol8-component-choice-gap-standard);
|
|
148
|
+
block-size: var(--_height);
|
|
149
|
+
padding-inline: var(--_inset);
|
|
150
|
+
background: var(--_surface);
|
|
151
|
+
border-block: var(--ol8-border-width-standard) solid var(--_edge);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* The ring's flat runs, continuing the terminals' strokes across the rail.
|
|
155
|
+
Two colours in one band, ordered outward from the silhouette. */
|
|
156
|
+
.ol8-chip__rail::before,
|
|
157
|
+
.ol8-chip__rail::after {
|
|
158
|
+
content: "";
|
|
159
|
+
position: absolute;
|
|
160
|
+
inset-inline: 0;
|
|
161
|
+
block-size: calc(var(--ol8-focus-ring-outerwidth) + var(--ol8-focus-ring-innerwidth));
|
|
162
|
+
}
|
|
163
|
+
.ol8-chip__rail::before {
|
|
164
|
+
inset-block-end: 100%;
|
|
165
|
+
background: linear-gradient(
|
|
166
|
+
var(--_focus-outer) 0 var(--ol8-focus-ring-outerwidth),
|
|
167
|
+
var(--_focus-inner) var(--ol8-focus-ring-outerwidth) 100%);
|
|
168
|
+
}
|
|
169
|
+
.ol8-chip__rail::after {
|
|
170
|
+
inset-block-start: 100%;
|
|
171
|
+
background: linear-gradient(
|
|
172
|
+
var(--_focus-inner) 0 var(--ol8-focus-ring-innerwidth),
|
|
173
|
+
var(--_focus-outer) var(--ol8-focus-ring-innerwidth) 100%);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.ol8-chip__label {
|
|
177
|
+
font-size: var(--_label-size);
|
|
178
|
+
line-height: var(--_label-line);
|
|
179
|
+
font-weight: var(--ol8-font-weight-regular);
|
|
180
|
+
color: var(--_ink);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ---- Mark ------------------------------------------------------------ */
|
|
184
|
+
/* Figma places the mark BEFORE the label in every variant, Remove included. */
|
|
185
|
+
.ol8-chip__mark {
|
|
186
|
+
flex: none;
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
inline-size: var(--ol8-component-choicechip-mark);
|
|
191
|
+
block-size: var(--ol8-component-choicechip-mark);
|
|
192
|
+
color: var(--_ink);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* The Check does not scale with its slot. Figma's `Icon / Selection / Check`
|
|
196
|
+
is a governed small master, not an Icon Frame instance: Size=Small and
|
|
197
|
+
Size=Standard both carry the same 10.2 x 6.9 artwork at the same 1.8 stroke,
|
|
198
|
+
centred in an 18 or 24 box. So the source box stays 24 units and overflows
|
|
199
|
+
the smaller slot, exactly as Figma draws it. */
|
|
200
|
+
.ol8-chip__mark > svg {
|
|
201
|
+
display: block;
|
|
202
|
+
flex: none;
|
|
203
|
+
inline-size: var(--ol8-size-icon-standard);
|
|
204
|
+
block-size: var(--ol8-size-icon-standard);
|
|
205
|
+
overflow: visible;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* Both marks read at the one governed optical stroke, which is what Figma
|
|
209
|
+
draws and what the iconography contract calls contextual stroke
|
|
210
|
+
normalization: peer marks in one family match apparent weight. */
|
|
211
|
+
.ol8-chip__mark > svg > * {
|
|
212
|
+
vector-effect: non-scaling-stroke;
|
|
213
|
+
stroke-width: var(--ol8-component-choice-optical-stroke);
|
|
214
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choice Chip (MOLECULE) — the Soft Hexagon. A real checkbox wearing the
|
|
3
|
+
* approved quiet chip appearance, so selection is carried by native checked
|
|
4
|
+
* state and a stronger edge, never by colour alone.
|
|
5
|
+
*/
|
|
6
|
+
export type Ol8ChipSize = 'compact' | 'standard' | 'comfortable' | 'large';
|
|
7
|
+
|
|
8
|
+
export declare const OL8_CHIP_SIZES: readonly Ol8ChipSize[];
|
|
9
|
+
|
|
10
|
+
export interface Ol8ChoiceChipOptions {
|
|
11
|
+
size?: Ol8ChipSize;
|
|
12
|
+
/** Figma's Selection axis. Maps to the input's checked state. */
|
|
13
|
+
selected?: boolean;
|
|
14
|
+
/** Figma's Show Mark boolean: the optional Check inside a selected chip. */
|
|
15
|
+
showMark?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
name?: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export declare function renderChoiceChip(label: string, options?: Ol8ChoiceChipOptions): string;
|
|
24
|
+
|
|
25
|
+
/** Mirrors each chip's input state onto the attribute the stylesheet reads. */
|
|
26
|
+
export declare function hydrateChoiceChips(root?: ParentNode): number;
|
|
27
|
+
export declare function syncChip(chip: Element): void;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Choice Chip (MOLECULE) — the Soft Hexagon.
|
|
3
|
+
*
|
|
4
|
+
* Figma: Select and Combobox / Choice Chip / Soft Hexagon 636:847 (8 variants,
|
|
5
|
+
* Size x Selection, plus a Label text property and a Show Mark boolean).
|
|
6
|
+
*
|
|
7
|
+
* From the Figma description:
|
|
8
|
+
* "Compact/Standard/Comfortable/Large use approved 30/36/42/42px visible
|
|
9
|
+
* geometry inside protected 48/48/48/60px targets. Fixed 12px terminals and
|
|
10
|
+
* tokenized 9/12/15/15px inner rail insets preserve one silhouette at every
|
|
11
|
+
* label width."
|
|
12
|
+
*
|
|
13
|
+
* Figma builds the silhouette from three boxes, and so does this: a fixed 12px
|
|
14
|
+
* left terminal, a centre rail that grows with the label, and a mirrored right
|
|
15
|
+
* terminal. Only the rail grows, which is why a short chip and a long chip keep
|
|
16
|
+
* the same shoulder depth and side angle. The contract is explicit that ends
|
|
17
|
+
* which scale with the text are prohibited.
|
|
18
|
+
*
|
|
19
|
+
* WHY SVG RATHER THAN clip-path
|
|
20
|
+
* The terminals are not polygons. Each has three small curves softening the
|
|
21
|
+
* shoulders and the point, and the contract rejects approximating that softness
|
|
22
|
+
* with extra vertices because it reads as an Octagon. `clip-path: polygon()`
|
|
23
|
+
* can only draw straight vertices, so the terminals are real paths.
|
|
24
|
+
*
|
|
25
|
+
* HOW ONE PATH SERVES FOUR HEIGHTS
|
|
26
|
+
* Read from Figma, every terminal path at 30, 36 and 42 has identical X values
|
|
27
|
+
* and Y values that scale exactly with the height: 1/36, 1/12, 5/12, 11/24,
|
|
28
|
+
* 13/24, 7/12, 11/12, 35/36 of the height, at every size. So one viewBox of
|
|
29
|
+
* 12 x 30 with `preserveAspectRatio="none"` and a fixed 12px width reproduces
|
|
30
|
+
* Figma at all four sizes: the height stretches, the width cannot.
|
|
31
|
+
* `vector-effect="non-scaling-stroke"` keeps the edge an even 2px through that
|
|
32
|
+
* uneven stretch.
|
|
33
|
+
*
|
|
34
|
+
* THE OPEN EDGE
|
|
35
|
+
* Figma draws two vectors per terminal: a closed fill and an open outline that
|
|
36
|
+
* "deliberately omits the hidden vertical closing edge", so the rail's own top
|
|
37
|
+
* and bottom borders continue the perimeter without a doubled seam. This keeps
|
|
38
|
+
* that construction. Figma reaches an inside stroke by scaling the outline path
|
|
39
|
+
* to 98.75% of the width; here the edge is clipped to the fill instead, which
|
|
40
|
+
* is the same rendered result stated directly.
|
|
41
|
+
*/
|
|
42
|
+
import { renderIconSvg } from '../../atoms/icon/icon.js';
|
|
43
|
+
|
|
44
|
+
export const OL8_CHIP_SIZES = ['compact', 'standard', 'comfortable', 'large'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The terminal, in the 12 x 30 space Figma draws it in. The closed path is the
|
|
48
|
+
* fill; the open one is the perimeter, missing the seam edge at x=12.
|
|
49
|
+
*/
|
|
50
|
+
export const OL8_CHIP_TERMINAL_FILL =
|
|
51
|
+
'M12 0 L8.96202541 0 C7.44303812 0 6.43037944 0.83333333 5.92405035 2.5 ' +
|
|
52
|
+
'L0.45569618 12.5 C-0.15189877 13.75 -0.15189877 16.25 0.45569618 17.5 ' +
|
|
53
|
+
'L5.92405035 27.5 C6.43037944 29.16666667 7.44303812 30 8.96202541 30 L12 30 Z';
|
|
54
|
+
export const OL8_CHIP_TERMINAL_EDGE =
|
|
55
|
+
'M12 0 L8.96202541 0 C7.44303812 0 6.43037944 0.83333333 5.92405035 2.5 ' +
|
|
56
|
+
'L0.45569618 12.5 C-0.15189877 13.75 -0.15189877 16.25 0.45569618 17.5 ' +
|
|
57
|
+
'L5.92405035 27.5 C6.43037944 29.16666667 7.44303812 30 8.96202541 30 L12 30';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One terminal. `side` only picks a class; the right terminal is the left one
|
|
61
|
+
* mirrored in CSS, so the two can never drift apart.
|
|
62
|
+
*
|
|
63
|
+
* The focus bands are strokes on the same path, painted before the fill and at
|
|
64
|
+
* double width, so their inner half lands under the fill and the visible half
|
|
65
|
+
* sits outside the silhouette. That is how a ring follows a hexagon instead of
|
|
66
|
+
* boxing it.
|
|
67
|
+
*/
|
|
68
|
+
function renderTerminal(uid, side) {
|
|
69
|
+
const clip = `${uid}-clip-${side}`;
|
|
70
|
+
return `<svg class="ol8-chip__terminal ol8-chip__terminal--${side}" viewBox="0 0 12 30" ` +
|
|
71
|
+
`preserveAspectRatio="none" aria-hidden="true" focusable="false">` +
|
|
72
|
+
`<clipPath id="${clip}"><path d="${OL8_CHIP_TERMINAL_FILL}"/></clipPath>` +
|
|
73
|
+
`<path class="ol8-chip__focus-outer" d="${OL8_CHIP_TERMINAL_EDGE}"/>` +
|
|
74
|
+
`<path class="ol8-chip__focus-inner" d="${OL8_CHIP_TERMINAL_EDGE}"/>` +
|
|
75
|
+
`<path class="ol8-chip__fill" d="${OL8_CHIP_TERMINAL_FILL}"/>` +
|
|
76
|
+
`<path class="ol8-chip__edge" d="${OL8_CHIP_TERMINAL_EDGE}" clip-path="url(#${clip})"/>` +
|
|
77
|
+
`</svg>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The silhouette plus its rail contents. Private: Figma names this the
|
|
82
|
+
* "Choice Chip / Geometry Owner" and both the public chip and the Token
|
|
83
|
+
* instantiate it rather than redrawing it.
|
|
84
|
+
*
|
|
85
|
+
* `mark` is markup for the rail's trailing slot, or an empty string. The mark
|
|
86
|
+
* follows the label, which is the anatomy the contract writes down as "Label +
|
|
87
|
+
* optional Remove mark", and one position serves Check and Remove alike so
|
|
88
|
+
* peer marks never end up on opposite sides.
|
|
89
|
+
*/
|
|
90
|
+
export function renderChipShape(uid, label, mark = '') {
|
|
91
|
+
return renderTerminal(uid, 'leading') +
|
|
92
|
+
`<span class="ol8-chip__rail">` +
|
|
93
|
+
`<span class="ol8-chip__label">${escapeText(label)}</span>` +
|
|
94
|
+
mark +
|
|
95
|
+
`</span>` +
|
|
96
|
+
renderTerminal(uid, 'trailing');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A Choice Chip is a real checkbox. The contract is explicit: "Every option is
|
|
101
|
+
* a real Checkbox rendered with the approved quiet Choice Chip appearance",
|
|
102
|
+
* with "native checked state" among the redundant cues, so selection is never
|
|
103
|
+
* carried by colour alone.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} label
|
|
106
|
+
* @param {{size?:string,selected?:boolean,showMark?:boolean,disabled?:boolean,
|
|
107
|
+
* name?:string,value?:string,id?:string,className?:string}} [options]
|
|
108
|
+
*/
|
|
109
|
+
export function renderChoiceChip(label, options = {}) {
|
|
110
|
+
const {
|
|
111
|
+
size = 'standard', selected = false, showMark = false, disabled = false,
|
|
112
|
+
name, value, id, className,
|
|
113
|
+
} = options;
|
|
114
|
+
|
|
115
|
+
if (!OL8_CHIP_SIZES.includes(size)) throw new Error(`[ol8] unknown chip size "${size}"`);
|
|
116
|
+
if (!label || !String(label).trim()) throw new Error('[ol8] Choice Chip requires a label');
|
|
117
|
+
|
|
118
|
+
const uid = id ?? `ol8-chip-${Math.random().toString(36).slice(2, 9)}`;
|
|
119
|
+
const mark = showMark
|
|
120
|
+
? `<span class="ol8-chip__mark" aria-hidden="true">${renderIconSvg('check')}</span>`
|
|
121
|
+
: '';
|
|
122
|
+
|
|
123
|
+
const inputAttrs = [
|
|
124
|
+
'type="checkbox"', 'class="ol8-chip__input"', `id="${uid}"`,
|
|
125
|
+
name ? `name="${escapeAttr(name)}"` : '',
|
|
126
|
+
value !== undefined ? `value="${escapeAttr(value)}"` : '',
|
|
127
|
+
selected ? 'checked' : '', disabled ? 'disabled' : '',
|
|
128
|
+
].filter(Boolean).join(' ');
|
|
129
|
+
|
|
130
|
+
const rowAttrs = [
|
|
131
|
+
`class="ol8-chip${className ? ` ${className}` : ''}"`,
|
|
132
|
+
`for="${uid}"`,
|
|
133
|
+
`data-ol8-size="${size}"`,
|
|
134
|
+
`data-ol8-selection="${selected ? 'selected' : 'inactive'}"`,
|
|
135
|
+
`data-ol8-availability="${disabled ? 'disabled' : 'enabled'}"`,
|
|
136
|
+
].filter(Boolean).join(' ');
|
|
137
|
+
|
|
138
|
+
return `<label ${rowAttrs}><input ${inputAttrs}>${renderChipShape(uid, label, mark)}</label>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Keeps each chip's Selection attribute in step with its input. A chip in a
|
|
143
|
+
* group is an ordinary checkbox, so the browser owns the toggling; this only
|
|
144
|
+
* mirrors the result onto the attribute the stylesheet reads.
|
|
145
|
+
*/
|
|
146
|
+
export function hydrateChoiceChips(root = document) {
|
|
147
|
+
const chips = [...root.querySelectorAll('.ol8-chip')];
|
|
148
|
+
for (const chip of chips) {
|
|
149
|
+
const input = chip.querySelector(':scope > .ol8-chip__input');
|
|
150
|
+
if (!input) continue;
|
|
151
|
+
syncChip(chip);
|
|
152
|
+
if (chip.dataset.ol8ChipBound === 'true') continue;
|
|
153
|
+
input.addEventListener('change', () => syncChip(chip));
|
|
154
|
+
chip.dataset.ol8ChipBound = 'true';
|
|
155
|
+
}
|
|
156
|
+
return chips.length;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Repaints one chip from the live state of its input. */
|
|
160
|
+
export function syncChip(chip) {
|
|
161
|
+
const input = chip.querySelector(':scope > .ol8-chip__input');
|
|
162
|
+
if (!input) return;
|
|
163
|
+
chip.dataset.ol8Selection = input.checked ? 'selected' : 'inactive';
|
|
164
|
+
chip.dataset.ol8Availability = input.disabled ? 'disabled' : 'enabled';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Private. Exported only so Token can reuse the same escaping; neither name
|
|
168
|
+
reaches the package index. */
|
|
169
|
+
export function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
170
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface. The terminal paths, the shape composer and the escape
|
|
3
|
+
* helpers stay module private on purpose: Figma names the terminals "Private
|
|
4
|
+
* Geometry" and the contract is explicit that they "are private construction
|
|
5
|
+
* inside the canonical Choice Chip molecule ... not published cap atoms, layer
|
|
6
|
+
* variants, or independently swappable components". Token reaches them by
|
|
7
|
+
* importing the module directly, exactly as its Figma counterpart instantiates
|
|
8
|
+
* the Geometry Owner.
|
|
9
|
+
*/
|
|
10
|
+
export { OL8_CHIP_SIZES, renderChoiceChip, hydrateChoiceChips, syncChip } from './choice-chip.js';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Form Message (MOLECULE)
|
|
3
|
+
* Figma: Form Message 177:13, five tones.
|
|
4
|
+
*
|
|
5
|
+
* Anatomy read from Figma: a horizontal row, gap Component / Text Field / Gap /
|
|
6
|
+
* Status, items centred, one Small status icon beside twelve over eighteen
|
|
7
|
+
* regular text. Tone changes exactly one thing, the inline content colour,
|
|
8
|
+
* which both the icon stroke and the text take.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.ol8-form-message {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
gap: var(--ol8-component-textfield-gap-status);
|
|
15
|
+
color: var(--_tone);
|
|
16
|
+
font-family: var(--ol8-font-family-functional);
|
|
17
|
+
font-size: var(--ol8-font-size-012);
|
|
18
|
+
font-weight: var(--ol8-font-weight-regular);
|
|
19
|
+
line-height: var(--ol8-font-line-018);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.ol8-form-message__icon { flex: none; color: var(--_tone); }
|
|
23
|
+
|
|
24
|
+
/* ---- Tone ------------------------------------------------------------- */
|
|
25
|
+
.ol8-form-message[data-ol8-tone="critical"] { --_tone: var(--ol8-color-feedbackcritical-inlinecontent); }
|
|
26
|
+
.ol8-form-message[data-ol8-tone="caution"] { --_tone: var(--ol8-color-feedbackcaution-inlinecontent); }
|
|
27
|
+
.ol8-form-message[data-ol8-tone="positive"] { --_tone: var(--ol8-color-feedbackpositive-inlinecontent); }
|
|
28
|
+
.ol8-form-message[data-ol8-tone="informative"] { --_tone: var(--ol8-color-feedbackinformative-inlinecontent); }
|
|
29
|
+
|
|
30
|
+
/* Pending reads as informative text, and Figma gives its loading mark the
|
|
31
|
+
primary icon colour rather than a feedback colour, because nothing has been
|
|
32
|
+
decided yet. */
|
|
33
|
+
.ol8-form-message[data-ol8-tone="pending"] {
|
|
34
|
+
--_tone: var(--ol8-color-feedbackinformative-inlinecontent);
|
|
35
|
+
}
|
|
36
|
+
.ol8-form-message[data-ol8-tone="pending"] .ol8-form-message__icon {
|
|
37
|
+
color: var(--ol8-color-icon-primary);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Same treatment the Button gives its busy mark: rotation is opt in and motion
|
|
41
|
+
guarded, and Reduced Motion keeps the static mark. */
|
|
42
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
43
|
+
.ol8-form-message[data-ol8-tone="pending"] .ol8-form-message__icon > svg {
|
|
44
|
+
animation: ol8-spin 900ms linear infinite;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form Message (MOLECULE). Figma 177:13. Tone maps to semantic message tone;
|
|
3
|
+
* Pending is the validating state rather than a fifth error tone.
|
|
4
|
+
*/
|
|
5
|
+
export type Ol8FormMessageTone = 'critical' | 'caution' | 'positive' | 'informative' | 'pending';
|
|
6
|
+
|
|
7
|
+
export declare const OL8_FORM_MESSAGE_TONES: readonly Ol8FormMessageTone[];
|
|
8
|
+
export declare const OL8_FORM_MESSAGE_ICONS: Readonly<Record<Ol8FormMessageTone, string>>;
|
|
9
|
+
|
|
10
|
+
export interface Ol8FormMessageOptions {
|
|
11
|
+
tone?: Ol8FormMessageTone;
|
|
12
|
+
/** Figma's Show Icon. */
|
|
13
|
+
icon?: boolean;
|
|
14
|
+
/** Give it an id so a field can point aria-describedby at it. */
|
|
15
|
+
id?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @throws on an unknown tone. */
|
|
20
|
+
export declare function renderFormMessage(message: string, options?: Ol8FormMessageOptions): string;
|
|
21
|
+
|
|
22
|
+
/** Enforces role and live region on existing `.ol8-form-message` markup. */
|
|
23
|
+
export declare function hydrateFormMessages(root?: ParentNode): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oneli8 · Form Message — headless behavior.
|
|
3
|
+
*
|
|
4
|
+
* Figma (177:13): "Canonical Oneli8 Form Message molecule. Tone maps to
|
|
5
|
+
* semantic message tone; Pending is resolved through validationStatus rather
|
|
6
|
+
* than treated as a fifth error tone. Icon and Message remain modular content
|
|
7
|
+
* atoms. Runtime role, status announcement and relationships live in code."
|
|
8
|
+
*
|
|
9
|
+
* So this module owns what Figma says code owns: the live region, the role that
|
|
10
|
+
* follows from the tone, and the icon each tone carries.
|
|
11
|
+
*/
|
|
12
|
+
import { renderIcon } from '../../atoms/icon/index.js';
|
|
13
|
+
|
|
14
|
+
/** The five tones Figma enumerates. Pending is the validating state, not an error. */
|
|
15
|
+
export const OL8_FORM_MESSAGE_TONES = ['critical', 'caution', 'positive', 'informative', 'pending'];
|
|
16
|
+
|
|
17
|
+
/** Figma pairs one status icon with each tone. Pending carries the loading mark. */
|
|
18
|
+
export const OL8_FORM_MESSAGE_ICONS = {
|
|
19
|
+
critical: 'critical',
|
|
20
|
+
caution: 'caution',
|
|
21
|
+
positive: 'positive',
|
|
22
|
+
informative: 'informative',
|
|
23
|
+
pending: 'loading',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} message
|
|
28
|
+
* @param {{tone?:string,icon?:boolean,id?:string,className?:string}} [options]
|
|
29
|
+
*/
|
|
30
|
+
export function renderFormMessage(message, options = {}) {
|
|
31
|
+
const { tone = 'critical', icon = true, id, className } = options;
|
|
32
|
+
|
|
33
|
+
if (!OL8_FORM_MESSAGE_TONES.includes(tone)) {
|
|
34
|
+
throw new Error(`[ol8] unknown form message tone "${tone}"`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Critical is the only tone a screen reader must interrupt for. Pending is
|
|
38
|
+
// progress, so it is polite. The rest report a result that already happened.
|
|
39
|
+
const live = tone === 'critical' ? 'assertive' : 'polite';
|
|
40
|
+
|
|
41
|
+
const attrs = [
|
|
42
|
+
`class="ol8-form-message${className ? ` ${className}` : ''}"`,
|
|
43
|
+
id ? `id="${escapeAttr(id)}"` : '',
|
|
44
|
+
`data-ol8-tone="${tone}"`,
|
|
45
|
+
tone === 'critical' ? 'role="alert"' : 'role="status"',
|
|
46
|
+
`aria-live="${live}"`,
|
|
47
|
+
].filter(Boolean).join(' ');
|
|
48
|
+
|
|
49
|
+
const mark = icon
|
|
50
|
+
? renderIcon(OL8_FORM_MESSAGE_ICONS[tone], { size: 18, className: 'ol8-form-message__icon' })
|
|
51
|
+
: '';
|
|
52
|
+
|
|
53
|
+
return `<div ${attrs}>${mark}<span class="ol8-form-message__text">${escapeText(message)}</span></div>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Enforces the runtime contract on existing markup. Idempotent. */
|
|
57
|
+
export function hydrateFormMessages(root = document) {
|
|
58
|
+
for (const el of root.querySelectorAll('.ol8-form-message')) {
|
|
59
|
+
const tone = el.dataset.ol8Tone || 'critical';
|
|
60
|
+
if (!OL8_FORM_MESSAGE_TONES.includes(tone)) {
|
|
61
|
+
console.warn(`[ol8] unknown form message tone "${tone}"`, el);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
el.dataset.ol8Tone = tone;
|
|
65
|
+
if (!el.hasAttribute('role')) el.setAttribute('role', tone === 'critical' ? 'alert' : 'status');
|
|
66
|
+
if (!el.hasAttribute('aria-live')) {
|
|
67
|
+
el.setAttribute('aria-live', tone === 'critical' ? 'assertive' : 'polite');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function escapeAttr(v) { return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
73
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './form-message.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderFormMessage, hydrateFormMessages, OL8_FORM_MESSAGE_TONES, OL8_FORM_MESSAGE_ICONS } from './form-message.js';
|
package/src/molecules/index.d.ts
CHANGED
|
@@ -2,3 +2,9 @@ export * from './button/index.js';
|
|
|
2
2
|
export * from './icon-button/index.js';
|
|
3
3
|
export * from './choice-item/index.js';
|
|
4
4
|
export * from './selection-option/index.js';
|
|
5
|
+
export * from './link/index.js';
|
|
6
|
+
export * from './form-message/index.js';
|
|
7
|
+
export * from './text-field/index.js';
|
|
8
|
+
export * from './select/index.js';
|
|
9
|
+
export * from './choice-chip/index.js';
|
|
10
|
+
export * from './token/index.js';
|
package/src/molecules/index.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export * from './button/index.js';
|
|
2
|
+
export * from './link/index.js';
|
|
2
3
|
export * from './icon-button/index.js';
|
|
3
4
|
export * from './choice-item/index.js';
|
|
4
5
|
export * from './selection-option/index.js';
|
|
6
|
+
export * from './form-message/index.js';
|
|
7
|
+
export * from './text-field/index.js';
|
|
8
|
+
export * from './select/index.js';
|
|
9
|
+
export * from './choice-chip/index.js';
|
|
10
|
+
export * from './token/index.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './link.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './link.js';
|