@oneli8/core 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +76 -0
  4. package/package.json +79 -0
  5. package/src/atoms/icon/icon.css +54 -0
  6. package/src/atoms/icon/icon.d.ts +19 -0
  7. package/src/atoms/icon/icon.js +81 -0
  8. package/src/atoms/icon/icons.generated.d.ts +38 -0
  9. package/src/atoms/icon/icons.generated.js +128 -0
  10. package/src/atoms/icon/index.d.ts +2 -0
  11. package/src/atoms/icon/index.js +2 -0
  12. package/src/atoms/index.d.ts +2 -0
  13. package/src/atoms/index.js +2 -0
  14. package/src/atoms/selection-indicator/index.d.ts +1 -0
  15. package/src/atoms/selection-indicator/index.js +1 -0
  16. package/src/atoms/selection-indicator/selection-indicator.css +140 -0
  17. package/src/atoms/selection-indicator/selection-indicator.d.ts +36 -0
  18. package/src/atoms/selection-indicator/selection-indicator.js +61 -0
  19. package/src/index.d.ts +3 -0
  20. package/src/index.js +3 -0
  21. package/src/materials/gem/gem.css +171 -0
  22. package/src/molecules/button/button.css +193 -0
  23. package/src/molecules/button/button.d.ts +51 -0
  24. package/src/molecules/button/button.js +134 -0
  25. package/src/molecules/button/index.d.ts +1 -0
  26. package/src/molecules/button/index.js +1 -0
  27. package/src/molecules/choice-item/choice-item.css +190 -0
  28. package/src/molecules/choice-item/choice-item.d.ts +54 -0
  29. package/src/molecules/choice-item/choice-item.js +153 -0
  30. package/src/molecules/choice-item/index.d.ts +1 -0
  31. package/src/molecules/choice-item/index.js +1 -0
  32. package/src/molecules/icon-button/icon-button.css +222 -0
  33. package/src/molecules/icon-button/icon-button.d.ts +40 -0
  34. package/src/molecules/icon-button/icon-button.js +121 -0
  35. package/src/molecules/icon-button/index.d.ts +1 -0
  36. package/src/molecules/icon-button/index.js +1 -0
  37. package/src/molecules/index.d.ts +4 -0
  38. package/src/molecules/index.js +4 -0
  39. package/src/molecules/selection-option/index.d.ts +1 -0
  40. package/src/molecules/selection-option/index.js +1 -0
  41. package/src/molecules/selection-option/selection-option.css +121 -0
  42. package/src/molecules/selection-option/selection-option.d.ts +42 -0
  43. package/src/molecules/selection-option/selection-option.js +97 -0
  44. package/src/organisms/choice-group/choice-group.css +93 -0
  45. package/src/organisms/choice-group/choice-group.d.ts +80 -0
  46. package/src/organisms/choice-group/choice-group.js +159 -0
  47. package/src/organisms/choice-group/index.d.ts +1 -0
  48. package/src/organisms/choice-group/index.js +1 -0
  49. package/src/organisms/index.d.ts +1 -0
  50. package/src/organisms/index.js +1 -0
  51. package/styles.css +13 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Oneli8 · Button
3
+ * Figma: section "Button" (1034:9968) — Primary 15:823, Secondary 15:824,
4
+ * Quiet 15:825, Destructive 15:826. 4 families × 4 sizes × 6 states.
5
+ *
6
+ * The governing rule comes from the Figma component description itself:
7
+ *
8
+ * "State → deterministic visual evidence … Runtime-only native button
9
+ * semantics, disabled, loading, pressed, fullWidth and iconMotion remain
10
+ * implemented and tested in code; they must not become fake visual axes."
11
+ *
12
+ * So the six Figma States are NOT six markup variants:
13
+ * Default → the base rule
14
+ * Hover → :hover
15
+ * Pressed → :active (and [aria-pressed="true"] for toggles)
16
+ * Focus → :focus-visible
17
+ * Disabled → [disabled] (native, not a class)
18
+ * Loading → [data-ol8-loading] (which also implies disabled + aria-busy)
19
+ *
20
+ * There is no .ol8-btn--hover. Emitting one is a bug.
21
+ */
22
+
23
+ .ol8-btn {
24
+ /* Per-variant channels. Families set these; the base rule never hardcodes. */
25
+ --_bg: transparent;
26
+ --_fg: currentColor;
27
+ --_bd: transparent;
28
+ --_bw: 0px;
29
+
30
+ /* Size channels, defaulted to Standard. */
31
+ --_h: var(--ol8-component-button-height-standard);
32
+ --_px: var(--ol8-component-button-paddinginline-standard);
33
+ --_r: var(--ol8-component-button-radius-standard);
34
+ --_gap: var(--ol8-component-button-gap-compact);
35
+ --_icon: var(--ol8-size-icon-small);
36
+
37
+ box-sizing: border-box; /* Figma strokes are inside the frame */
38
+ display: inline-flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ gap: var(--_gap);
42
+ block-size: var(--_h);
43
+ padding-inline: var(--_px);
44
+ padding-block: 0;
45
+ border: var(--_bw) solid var(--_bd);
46
+ border-radius: var(--_r);
47
+ background: var(--_bg);
48
+ color: var(--_fg);
49
+
50
+ font-family: var(--ol8-font-family-functional);
51
+ font-size: var(--ol8-font-size-016);
52
+ line-height: var(--ol8-font-line-024);
53
+ font-weight: var(--ol8-font-weight-regular);
54
+ letter-spacing: 0;
55
+ /* AR One Sans variable axis. Figma sets this on every functional text node;
56
+ omitting it shifts glyph advances and the button measures narrow. */
57
+ font-variation-settings: var(--ol8-font-variation-functional);
58
+
59
+ cursor: pointer;
60
+ user-select: none;
61
+ white-space: nowrap;
62
+ text-decoration: none; /* when rendered as <a class="ol8-btn"> */
63
+ -webkit-appearance: none;
64
+ appearance: none;
65
+ }
66
+
67
+ .ol8-btn__label { white-space: nowrap; }
68
+
69
+ /* Button size governs icon size — the Figma Icon Frame is driven by the
70
+ button, never set on the glyph. Higher specificity than .ol8-icon's own
71
+ [data-ol8-icon-size] rule, deliberately. */
72
+ .ol8-btn .ol8-icon { --_ol8-icon-size: var(--_icon); }
73
+
74
+ /* ---- Size (Figma: Size variant axis) --------------------------------- */
75
+ .ol8-btn[data-ol8-size="compact"] {
76
+ --_h: var(--ol8-component-button-height-compact);
77
+ --_px: var(--ol8-component-button-paddinginline-compact);
78
+ --_r: var(--ol8-component-button-radius-compact);
79
+ --_gap: var(--ol8-component-button-gap-compact);
80
+ --_icon: var(--ol8-size-icon-small);
81
+ }
82
+ .ol8-btn[data-ol8-size="standard"] {
83
+ --_h: var(--ol8-component-button-height-standard);
84
+ --_px: var(--ol8-component-button-paddinginline-standard);
85
+ --_r: var(--ol8-component-button-radius-standard);
86
+ --_gap: var(--ol8-component-button-gap-compact); /* shared, by design */
87
+ --_icon: var(--ol8-size-icon-small);
88
+ }
89
+ .ol8-btn[data-ol8-size="comfortable"] {
90
+ --_h: var(--ol8-component-button-height-comfortable);
91
+ --_px: var(--ol8-component-button-paddinginline-comfortable);
92
+ --_r: var(--ol8-component-button-radius-comfortable);
93
+ --_gap: var(--ol8-component-button-gap-comfortable);
94
+ --_icon: var(--ol8-size-icon-standard);
95
+ }
96
+ .ol8-btn[data-ol8-size="large"] {
97
+ --_h: var(--ol8-component-button-height-large);
98
+ --_px: var(--ol8-component-button-paddinginline-large);
99
+ --_r: var(--ol8-component-button-radius-large);
100
+ --_gap: var(--ol8-component-button-gap-comfortable); /* shared, by design */
101
+ --_icon: var(--ol8-size-icon-standard);
102
+ }
103
+
104
+ /* ---- Family: Primary ------------------------------------------------- */
105
+ .ol8-btn--primary { --_bg: var(--ol8-color-actionprimary-default); --_fg: var(--ol8-color-actionprimary-content); }
106
+ .ol8-btn--primary:hover:not(:disabled):not([data-ol8-loading]) { --_bg: var(--ol8-color-actionprimary-hover); }
107
+ .ol8-btn--primary:active:not(:disabled):not([data-ol8-loading]),
108
+ .ol8-btn--primary[aria-pressed="true"]:not(:disabled) { --_bg: var(--ol8-color-actionprimary-pressed); }
109
+ .ol8-btn--primary:disabled, .ol8-btn--primary[data-ol8-loading] {
110
+ --_bg: var(--ol8-color-actionprimary-disabled);
111
+ --_fg: var(--ol8-color-actionprimary-disabledcontent);
112
+ }
113
+
114
+ /* ---- Family: Destructive (same shape as Primary) --------------------- */
115
+ .ol8-btn--destructive { --_bg: var(--ol8-color-actiondestructive-default); --_fg: var(--ol8-color-actiondestructive-content); }
116
+ .ol8-btn--destructive:hover:not(:disabled):not([data-ol8-loading]) { --_bg: var(--ol8-color-actiondestructive-hover); }
117
+ .ol8-btn--destructive:active:not(:disabled):not([data-ol8-loading]),
118
+ .ol8-btn--destructive[aria-pressed="true"]:not(:disabled) { --_bg: var(--ol8-color-actiondestructive-pressed); }
119
+ .ol8-btn--destructive:disabled, .ol8-btn--destructive[data-ol8-loading] {
120
+ --_bg: var(--ol8-color-actiondestructive-disabled);
121
+ --_fg: var(--ol8-color-actiondestructive-disabledcontent);
122
+ }
123
+
124
+ /* ---- Family: Secondary ----------------------------------------------
125
+ Secondary and Quiet carry byte-identical color roles in Figma. The ONLY
126
+ differentiator is this boundary: Secondary has a 2px stroke whose color
127
+ tracks the content color; Quiet has none. Verified — Quiet's node binds no
128
+ --ol8-component-button-secondaryboundary at all. */
129
+ .ol8-btn--secondary {
130
+ --_bw: var(--ol8-component-button-secondaryboundary);
131
+ --_bg: var(--ol8-color-actionsecondary-surfacedefault);
132
+ --_fg: var(--ol8-color-actionsecondary-contentdefault);
133
+ --_bd: var(--ol8-color-actionsecondary-contentdefault);
134
+ }
135
+ .ol8-btn--secondary:hover:not(:disabled):not([data-ol8-loading]) {
136
+ --_bg: var(--ol8-color-actionsecondary-surfacehover);
137
+ --_fg: var(--ol8-color-actionsecondary-contenthover);
138
+ --_bd: var(--ol8-color-actionsecondary-contenthover);
139
+ }
140
+ .ol8-btn--secondary:active:not(:disabled):not([data-ol8-loading]),
141
+ .ol8-btn--secondary[aria-pressed="true"]:not(:disabled) {
142
+ --_bg: var(--ol8-color-actionsecondary-surfacepressed);
143
+ --_fg: var(--ol8-color-actionsecondary-contentpressed);
144
+ --_bd: var(--ol8-color-actionsecondary-contentpressed);
145
+ }
146
+ .ol8-btn--secondary:disabled, .ol8-btn--secondary[data-ol8-loading] {
147
+ --_bg: var(--ol8-color-actionsecondary-surfacedisabled);
148
+ --_fg: var(--ol8-color-actionsecondary-contentdisabled);
149
+ --_bd: var(--ol8-color-actionsecondary-contentdisabled);
150
+ }
151
+
152
+ /* ---- Family: Quiet (identical colors, no boundary) ------------------- */
153
+ .ol8-btn--quiet {
154
+ --_bg: var(--ol8-color-actionquiet-surfacedefault);
155
+ --_fg: var(--ol8-color-actionquiet-contentdefault);
156
+ }
157
+ .ol8-btn--quiet:hover:not(:disabled):not([data-ol8-loading]) {
158
+ --_bg: var(--ol8-color-actionquiet-surfacehover);
159
+ --_fg: var(--ol8-color-actionquiet-contenthover);
160
+ }
161
+ .ol8-btn--quiet:active:not(:disabled):not([data-ol8-loading]),
162
+ .ol8-btn--quiet[aria-pressed="true"]:not(:disabled) {
163
+ --_bg: var(--ol8-color-actionquiet-surfacepressed);
164
+ --_fg: var(--ol8-color-actionquiet-contentpressed);
165
+ }
166
+ .ol8-btn--quiet:disabled, .ol8-btn--quiet[data-ol8-loading] {
167
+ --_bg: var(--ol8-color-actionquiet-surfacedisabled);
168
+ --_fg: var(--ol8-color-actionquiet-contentdisabled);
169
+ }
170
+
171
+ /* ---- Focus ----------------------------------------------------------
172
+ Figma draws the focus ring as an INSIDE stroke, so the focused variant is
173
+ exactly the same size as the unfocused one (83×42 either way, Standard).
174
+ An inset box-shadow reproduces that without disturbing layout or fighting
175
+ Secondary's own 2px boundary — `outline` would sit outside and grow it. */
176
+ .ol8-btn:focus-visible {
177
+ outline: none;
178
+ box-shadow: inset 0 0 0 var(--ol8-focus-ring-innerwidth) var(--ol8-color-focus-outer);
179
+ }
180
+
181
+ /* ---- Disabled / Loading --------------------------------------------- */
182
+ .ol8-btn:disabled, .ol8-btn[data-ol8-loading] { cursor: not-allowed; }
183
+ .ol8-btn[data-ol8-loading] { cursor: progress; }
184
+
185
+ /* "Platform motion adapters may rotate it; Reduced Motion keeps the static
186
+ busy mark." — Figma 379:7. Rotation is opt-in and motion-guarded. */
187
+ @media (prefers-reduced-motion: no-preference) {
188
+ .ol8-btn[data-ol8-loading] .ol8-btn__spinner > svg { animation: ol8-spin 900ms linear infinite; }
189
+ }
190
+ @keyframes ol8-spin { to { transform: rotate(360deg); } }
191
+
192
+ /* ---- fullWidth (runtime-only, per the Figma description) ------------- */
193
+ .ol8-btn[data-ol8-fullwidth] { display: flex; inline-size: 100%; }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Button (MOLECULE) — headless behavior. Native semantics, disabled, loading,
3
+ * pressed and fullWidth live here, never as visual-only axes.
4
+ */
5
+ import type { Ol8IconName } from '../../atoms/icon/icons.generated.js';
6
+
7
+ export type Ol8ButtonVariant = 'primary' | 'secondary' | 'quiet' | 'destructive';
8
+ export type Ol8ButtonSize = 'compact' | 'standard' | 'comfortable' | 'large';
9
+ export type Ol8Material = 'regular' | 'gem';
10
+ /** Gem is approved for Primary and Secondary only (Figma 100:3). */
11
+ export type Ol8GemVariant = 'primary' | 'secondary';
12
+
13
+ export declare const OL8_BUTTON_VARIANTS: readonly Ol8ButtonVariant[];
14
+ export declare const OL8_BUTTON_SIZES: readonly Ol8ButtonSize[];
15
+ export declare const OL8_MATERIALS: readonly Ol8Material[];
16
+ export declare const GEM_VARIANTS: readonly Ol8GemVariant[];
17
+
18
+ /** Compact+Standard take the 18px icon; Comfortable+Large take 24px. */
19
+ export declare function iconSizeForButton(size: Ol8ButtonSize): 18 | 24;
20
+
21
+ export interface Ol8ButtonOptions {
22
+ variant?: Ol8ButtonVariant;
23
+ size?: Ol8ButtonSize;
24
+ leadingIcon?: Ol8IconName;
25
+ trailingIcon?: Ol8IconName;
26
+ disabled?: boolean;
27
+ loading?: boolean;
28
+ /** Emits aria-pressed. Omit entirely for a non-toggle button. */
29
+ pressed?: boolean;
30
+ fullWidth?: boolean;
31
+ /** Renders an <a> instead of a <button>. */
32
+ href?: string;
33
+ /** Ignored when href is set. Defaults to "button", never a stray submit. */
34
+ type?: 'button' | 'submit' | 'reset';
35
+ /** Throws for quiet and destructive: Gem has no approved treatment there. */
36
+ material?: Ol8Material;
37
+ /**
38
+ * By default the spinner takes the leading slot so the button never shows two
39
+ * competing glyphs. Set true for Figma's literal reading (icon AND spinner).
40
+ */
41
+ keepLeadingIconWhileLoading?: boolean;
42
+ className?: string;
43
+ }
44
+
45
+ /** @throws if variant, size or material is unknown, or Gem is asked for on quiet/destructive. */
46
+ export declare function renderButton(label: string, options?: Ol8ButtonOptions): string;
47
+
48
+ /** Enforces the runtime contract on existing `.ol8-btn` markup. Idempotent. */
49
+ export declare function hydrateButtons(root?: ParentNode): void;
50
+
51
+ export declare function setButtonLoading(el: Element, loading: boolean): void;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Oneli8 · Button — headless behavior.
3
+ *
4
+ * The Figma component description is explicit that native semantics, disabled,
5
+ * loading, pressed and fullWidth "remain implemented and tested in code; they
6
+ * must not become fake visual axes." This module is that code.
7
+ */
8
+ import { renderIcon, renderIconSvg, isOl8IconName } from '../../atoms/icon/icon.js';
9
+
10
+ export const OL8_BUTTON_VARIANTS = ['primary', 'secondary', 'quiet', 'destructive'];
11
+ export const OL8_BUTTON_SIZES = ['compact', 'standard', 'comfortable', 'large'];
12
+ export const OL8_MATERIALS = ['regular', 'gem'];
13
+ /** Gem is approved for Primary and Secondary only (Figma 100:3). */
14
+ export const GEM_VARIANTS = ['primary', 'secondary'];
15
+
16
+ /** Compact+Standard take the 18px icon; Comfortable+Large take 24px. */
17
+ export function iconSizeForButton(size) {
18
+ return size === 'comfortable' || size === 'large' ? 24 : 18;
19
+ }
20
+
21
+ /**
22
+ * @param {string} label
23
+ * @param {{variant?:string,size?:string,leadingIcon?:string,trailingIcon?:string,
24
+ * disabled?:boolean,loading?:boolean,pressed?:boolean,fullWidth?:boolean,
25
+ * href?:string,type?:string,className?:string}} [options]
26
+ */
27
+ export function renderButton(label, options = {}) {
28
+ const {
29
+ variant = 'primary', size = 'standard', leadingIcon, trailingIcon,
30
+ disabled = false, loading = false, pressed, fullWidth = false,
31
+ href, type = 'button', className, material = 'regular',
32
+ keepLeadingIconWhileLoading = false,
33
+ } = options;
34
+
35
+ if (!OL8_BUTTON_VARIANTS.includes(variant)) throw new Error(`[ol8] unknown button variant "${variant}"`);
36
+ if (!OL8_BUTTON_SIZES.includes(size)) throw new Error(`[ol8] unknown button size "${size}"`);
37
+ if (!OL8_MATERIALS.includes(material)) throw new Error(`[ol8] unknown material "${material}"`);
38
+ if (material === 'gem' && !GEM_VARIANTS.includes(variant)) {
39
+ throw new Error(`[ol8] Gem has no approved treatment for "${variant}" — Figma 100:3 says Quiet and Destructive use the regular material.`);
40
+ }
41
+
42
+ const isLink = typeof href === 'string';
43
+ const inert = disabled || loading;
44
+ const iconSize = iconSizeForButton(size);
45
+
46
+ const attrs = [
47
+ `class="ol8-btn ol8-btn--${variant}${className ? ` ${className}` : ''}"`,
48
+ `data-ol8-size="${size}"`,
49
+ isLink ? (inert ? 'role="button" aria-disabled="true" tabindex="-1"' : `href="${escapeAttr(href)}"`)
50
+ : `type="${type}"`,
51
+ !isLink && inert ? 'disabled' : '',
52
+ loading ? 'data-ol8-loading="true" aria-busy="true"' : '',
53
+ pressed !== undefined ? `aria-pressed="${pressed}"` : '',
54
+ fullWidth ? 'data-ol8-fullwidth="true"' : '',
55
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
56
+ loading && keepLeadingIconWhileLoading ? 'data-ol8-loading-keep-icon="true"' : '',
57
+ ].filter(Boolean).join(' ');
58
+
59
+ // Loading + leading icon is genuinely ambiguous in the source. Figma's
60
+ // codegen emits BOTH the leading icon and the spinner, but the measured
61
+ // Loading widths (101/107/122/134 = default + spinner + gap) come from
62
+ // variants that carry no leading icon, so they do not disambiguate.
63
+ //
64
+ // Default here: the spinner TAKES the leading slot, so the button never
65
+ // shows two competing leading glyphs and its width stays predictable.
66
+ // Pass keepLeadingIconWhileLoading to get Figma's literal reading.
67
+ const spinner = `<span class="ol8-btn__spinner ol8-icon" aria-hidden="true">${renderIconSvg('loading')}</span>`;
68
+ const leadingGlyph = leadingIcon ? renderIcon(leadingIcon, { size: iconSize }) : '';
69
+ const leading = loading
70
+ ? (keepLeadingIconWhileLoading ? leadingGlyph + spinner : spinner)
71
+ : leadingGlyph;
72
+ const trailing = trailingIcon ? renderIcon(trailingIcon, { size: iconSize }) : '';
73
+
74
+ const tag = isLink ? 'a' : 'button';
75
+ return `<${tag} ${attrs}>${leading}<span class="ol8-btn__label">${escapeText(label)}</span>${trailing}</${tag}>`;
76
+ }
77
+
78
+ /**
79
+ * Enforces the runtime contract on existing `.ol8-btn` markup. Idempotent.
80
+ * - <button> gets an explicit type (default "button", never a stray submit)
81
+ * - [data-ol8-loading] implies disabled + aria-busy and grows a spinner
82
+ * - links can't be natively disabled, so they get aria-disabled + tabindex=-1
83
+ * and their activation is swallowed
84
+ */
85
+ export function hydrateButtons(root = document) {
86
+ for (const el of root.querySelectorAll('.ol8-btn')) {
87
+ const isLink = el.tagName === 'A';
88
+ const loading = el.dataset.ol8Loading === 'true';
89
+
90
+ if (!isLink && !el.hasAttribute('type')) el.setAttribute('type', 'button');
91
+
92
+ if (loading) {
93
+ el.setAttribute('aria-busy', 'true');
94
+ if (isLink) { el.setAttribute('aria-disabled', 'true'); el.setAttribute('tabindex', '-1'); }
95
+ else el.disabled = true;
96
+
97
+ if (!el.querySelector('.ol8-btn__spinner')) {
98
+ // Same call as renderButton: the spinner takes the leading slot unless
99
+ // data-ol8-loading-keep-icon opts into Figma's literal both-glyphs read.
100
+ if (el.dataset.ol8LoadingKeepIcon !== 'true') {
101
+ el.querySelector(':scope > .ol8-icon:first-child')?.remove();
102
+ }
103
+ const spinner = document.createElement('span');
104
+ spinner.className = 'ol8-btn__spinner ol8-icon';
105
+ spinner.setAttribute('aria-hidden', 'true');
106
+ spinner.innerHTML = renderIconSvg('loading');
107
+ const keep = el.dataset.ol8LoadingKeepIcon === 'true';
108
+ const after = keep ? el.querySelector(':scope > .ol8-icon:first-child') : null;
109
+ if (after) after.after(spinner); else el.prepend(spinner);
110
+ }
111
+ } else {
112
+ el.removeAttribute('aria-busy');
113
+ el.querySelector('.ol8-btn__spinner')?.remove();
114
+ }
115
+
116
+ if (isLink && (el.getAttribute('aria-disabled') === 'true') && !el.dataset.ol8InertBound) {
117
+ el.addEventListener('click', swallow);
118
+ el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') swallow(e); });
119
+ el.dataset.ol8InertBound = 'true';
120
+ }
121
+ }
122
+ }
123
+
124
+ function swallow(event) { event.preventDefault(); event.stopPropagation(); }
125
+
126
+ /** Flip a button into or out of its loading state at runtime. */
127
+ export function setButtonLoading(el, loading) {
128
+ if (loading) el.dataset.ol8Loading = 'true';
129
+ else delete el.dataset.ol8Loading;
130
+ hydrateButtons(el.parentNode ?? document);
131
+ }
132
+
133
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
134
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './button.js';
@@ -0,0 +1 @@
1
+ export * from './button.js';
@@ -0,0 +1,190 @@
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: var(--ol8-font-variation-functional);
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
+ @media (prefers-reduced-transparency: reduce) {
187
+ .ol8-choice[data-ol8-material="gem"] > .ol8-choice__surface {
188
+ background: var(--ol8-material-gem-color-quiet-opaqueequivalent);
189
+ }
190
+ }
@@ -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;