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