@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,121 @@
1
+ /**
2
+ * Oneli8 · Selection Option (MOLECULE)
3
+ * Figma: Select and Combobox -> 01 · Atomic Selection Building Blocks
4
+ * Building Blocks / Selection Option 570:122 (8 variants)
5
+ * Size Standard|Large x Selection x Availability
6
+ *
7
+ * From the Figma description (570:122):
8
+ * "Description is associated through aria-describedby and REMAINS OUTSIDE
9
+ * the option accessible name. Selection Popup composes this owner;
10
+ * KEYBOARD FOCUS REMAINS ON THE COMBOBOX INPUT through
11
+ * aria-activedescendant."
12
+ *
13
+ * Two consequences that shape everything here:
14
+ * - the option is never focusable and carries no tabindex
15
+ * - "active" (the aria-activedescendant highlight) is a DIFFERENT thing from
16
+ * :hover and from selected, so it gets its own attribute
17
+ *
18
+ * Row geometry: 48px (Standard) / 60px (Large) minimum, but the content box
19
+ * stays 24px tall in both — the padding absorbs the difference.
20
+ * padding-block = (minimum - 24) / 2 -> 12px and 18px
21
+ */
22
+
23
+ .ol8-option {
24
+ --_min: var(--ol8-component-selectionoption-minimum-standard);
25
+ --_inset: var(--ol8-component-selectionoption-inset-standard);
26
+
27
+ box-sizing: border-box;
28
+ display: flex;
29
+ align-items: center;
30
+ min-block-size: var(--_min);
31
+ font-family: var(--ol8-font-family-functional);
32
+ font-variation-settings: var(--ol8-font-variation-functional);
33
+ list-style: none;
34
+ cursor: pointer;
35
+ }
36
+ .ol8-option[data-ol8-size="large"] {
37
+ --_min: var(--ol8-component-selectionoption-minimum-large);
38
+ --_inset: var(--ol8-component-selectionoption-inset-large);
39
+ }
40
+ .ol8-option[aria-disabled="true"] { cursor: not-allowed; }
41
+
42
+ /* The surface carries every wash and the focus ring; the row itself stays
43
+ clean so a popup can lay options out without fighting them. */
44
+ .ol8-option__surface {
45
+ box-sizing: border-box;
46
+ position: relative;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: var(--ol8-component-choice-gap-standard);
50
+ flex: 1 0 0;
51
+ min-inline-size: 0;
52
+ block-size: 100%;
53
+ padding-inline: var(--_inset);
54
+ padding-block: calc((var(--_min) - var(--ol8-font-line-024)) / 2);
55
+ border-radius: var(--ol8-shape-radius-medium);
56
+ overflow: clip;
57
+ }
58
+
59
+ /* Selected, hover and active all paint the same wash. Selected is a state of
60
+ the data; active is the aria-activedescendant cursor; hover is the pointer.
61
+ They coincide visually by design. */
62
+ .ol8-option[aria-selected="true"] > .ol8-option__surface,
63
+ .ol8-option[data-ol8-active="true"] > .ol8-option__surface,
64
+ .ol8-option:hover:not([aria-disabled="true"]) > .ol8-option__surface {
65
+ background: var(--ol8-color-selection-surface);
66
+ }
67
+ .ol8-option:active:not([aria-disabled="true"]) > .ol8-option__surface {
68
+ background: var(--ol8-color-selection-surface);
69
+ opacity: 0.84;
70
+ }
71
+
72
+ /* Focus lives on the combobox input, never here — but the option still shows
73
+ the active ring when a popup marks it, so the two-tone ring is reproduced. */
74
+ .ol8-option[data-ol8-focus-ring="true"] > .ol8-option__surface {
75
+ border: var(--ol8-focus-ring-outerwidth) solid var(--ol8-color-focus-outer);
76
+ }
77
+ .ol8-option[data-ol8-focus-ring="true"] > .ol8-option__surface::after {
78
+ content: "";
79
+ position: absolute;
80
+ inset: 2px;
81
+ border: var(--ol8-focus-ring-innerwidth) solid var(--ol8-color-focus-inner);
82
+ border-radius: 7px;
83
+ pointer-events: none;
84
+ }
85
+
86
+ .ol8-option__text {
87
+ display: flex;
88
+ flex-direction: column;
89
+ flex: 1 0 0;
90
+ min-inline-size: 0;
91
+ }
92
+ .ol8-option__label {
93
+ font-size: var(--ol8-font-size-016);
94
+ line-height: var(--ol8-font-line-024);
95
+ font-weight: var(--ol8-font-weight-regular);
96
+ color: var(--ol8-color-text-primary);
97
+ overflow-wrap: break-word;
98
+ }
99
+ .ol8-option__description {
100
+ font-size: var(--ol8-font-size-012);
101
+ line-height: var(--ol8-font-line-018);
102
+ letter-spacing: 0.12px;
103
+ color: var(--ol8-color-text-secondary);
104
+ overflow-wrap: break-word;
105
+ }
106
+ .ol8-option[aria-disabled="true"] .ol8-option__label,
107
+ .ol8-option[aria-disabled="true"] .ol8-option__description {
108
+ color: var(--ol8-color-text-disabled);
109
+ }
110
+
111
+ /* The trailing slot is ALWAYS reserved, selected or not, so the label column
112
+ never shifts when selection changes. Figma keeps an empty 18px node there. */
113
+ .ol8-option__check {
114
+ flex: none;
115
+ inline-size: var(--ol8-size-icon-small);
116
+ block-size: var(--ol8-size-icon-small);
117
+ color: var(--ol8-color-selection-content);
118
+ }
119
+ .ol8-option__check > svg { display: block; inline-size: 100%; block-size: 100%; }
120
+
121
+ .ol8-option .ol8-icon { --_ol8-icon-size: var(--ol8-size-icon-small); }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Selection Option (MOLECULE) — headless. The option is NEVER focusable: a
3
+ * listbox moves a virtual cursor with aria-activedescendant while real focus
4
+ * stays on the controlling combobox input.
5
+ */
6
+ import type { Ol8IconName } from '../../atoms/icon/icons.generated.js';
7
+
8
+ export type Ol8OptionSize = 'standard' | 'large';
9
+ export declare const OL8_OPTION_SIZES: readonly Ol8OptionSize[];
10
+
11
+ export interface Ol8SelectionOptionOptions {
12
+ value?: string;
13
+ size?: Ol8OptionSize;
14
+ selected?: boolean;
15
+ /** The listbox's virtual cursor, not DOM focus. */
16
+ active?: boolean;
17
+ /** Options cannot be natively disabled, so this emits aria-disabled. */
18
+ disabled?: boolean;
19
+ /** Associated with aria-describedby, kept out of the accessible name. */
20
+ description?: string;
21
+ leadingIcon?: Ol8IconName;
22
+ id?: string;
23
+ focusRing?: boolean;
24
+ className?: string;
25
+ }
26
+
27
+ export declare function renderSelectionOption(
28
+ label: string,
29
+ options?: Ol8SelectionOptionOptions,
30
+ ): string;
31
+
32
+ export declare function setOptionSelected(option: Element, selected: boolean): void;
33
+
34
+ /**
35
+ * Moves the virtual cursor within a listbox: marks one option active and points
36
+ * the controller's aria-activedescendant at it. Real focus is never moved.
37
+ */
38
+ export declare function setActiveOption(
39
+ listbox: Element,
40
+ option: Element,
41
+ controller: Element,
42
+ ): void;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Oneli8 · Selection Option (MOLECULE) — headless.
3
+ *
4
+ * Figma (570:122): "Description is associated through aria-describedby and
5
+ * remains outside the option accessible name. Selection Popup composes this
6
+ * owner; keyboard focus remains on the Combobox input through
7
+ * aria-activedescendant."
8
+ *
9
+ * That single sentence sets the whole contract:
10
+ * - role="option", named by its LABEL only. The description is referenced,
11
+ * never nested into the name, so aria-labelledby points at the label and
12
+ * aria-describedby at the description.
13
+ * - the option is NOT focusable. No tabindex, ever. A listbox moves a
14
+ * virtual cursor with aria-activedescendant while real focus stays put.
15
+ * - "active" (the cursor) is distinct from "selected" (the data) and from
16
+ * :hover (the pointer), so it is its own attribute.
17
+ * - options cannot use the `disabled` attribute — it is not valid on <li> —
18
+ * so unavailability is aria-disabled.
19
+ */
20
+ import { renderIcon, renderIconSvg, isOl8IconName } from '../../atoms/icon/icon.js';
21
+
22
+ export const OL8_OPTION_SIZES = ['standard', 'large'];
23
+
24
+ /**
25
+ * @param {string} label
26
+ * @param {{value?:string,size?:string,selected?:boolean,active?:boolean,disabled?:boolean,
27
+ * description?:string,leadingIcon?:string,id?:string,focusRing?:boolean,className?:string}} [options]
28
+ */
29
+ export function renderSelectionOption(label, options = {}) {
30
+ const {
31
+ value, size = 'standard', selected = false, active = false, disabled = false,
32
+ description, leadingIcon, id, focusRing = false, className,
33
+ } = options;
34
+
35
+ if (!OL8_OPTION_SIZES.includes(size)) throw new Error(`[ol8] unknown option size "${size}"`);
36
+ if (!label || !String(label).trim()) throw new Error('[ol8] a selection option requires a label');
37
+ if (leadingIcon && !isOl8IconName(leadingIcon)) throw new Error(`[ol8] unknown icon "${leadingIcon}"`);
38
+
39
+ const uid = id ?? `ol8-option-${Math.random().toString(36).slice(2, 9)}`;
40
+ const labelId = `${uid}-label`;
41
+ const descId = description ? `${uid}-desc` : null;
42
+
43
+ const attrs = [
44
+ `class="ol8-option${className ? ` ${className}` : ''}"`,
45
+ 'role="option"',
46
+ `id="${uid}"`,
47
+ value !== undefined ? `data-ol8-value="${escapeAttr(value)}"` : '',
48
+ `data-ol8-size="${size}"`,
49
+ `aria-selected="${selected ? 'true' : 'false'}"`,
50
+ active ? 'data-ol8-active="true"' : '',
51
+ disabled ? 'aria-disabled="true"' : '',
52
+ focusRing ? 'data-ol8-focus-ring="true"' : '',
53
+ // Named by the label alone; the description is referenced, not nested.
54
+ `aria-labelledby="${labelId}"`,
55
+ descId ? `aria-describedby="${descId}"` : '',
56
+ ].filter(Boolean).join(' ');
57
+
58
+ const lead = leadingIcon ? renderIcon(leadingIcon, { size: 18 }) : '';
59
+ const check = `<span class="ol8-option__check" aria-hidden="true">${selected ? renderIconSvg('check') : ''}</span>`;
60
+
61
+ return `<li ${attrs}><span class="ol8-option__surface">` +
62
+ lead +
63
+ `<span class="ol8-option__text">` +
64
+ `<span class="ol8-option__label" id="${labelId}">${escapeText(label)}</span>` +
65
+ (description ? `<span class="ol8-option__description" id="${descId}">${escapeText(description)}</span>` : '') +
66
+ `</span>` +
67
+ check +
68
+ `</span></li>`;
69
+ }
70
+
71
+ /** Flips one option's selected state and repaints its trailing check. */
72
+ export function setOptionSelected(option, selected) {
73
+ option.setAttribute('aria-selected', selected ? 'true' : 'false');
74
+ const check = option.querySelector('.ol8-option__check');
75
+ if (check) check.innerHTML = selected ? renderIconSvg('check') : '';
76
+ }
77
+
78
+ /**
79
+ * Moves the virtual cursor within a listbox: marks one option active and
80
+ * points the controlling element's aria-activedescendant at it. Real focus is
81
+ * never moved, which is the entire point of the pattern.
82
+ */
83
+ export function setActiveOption(listbox, option, controller) {
84
+ for (const other of listbox.querySelectorAll('.ol8-option[data-ol8-active="true"]')) {
85
+ if (other !== option) delete other.dataset.ol8Active;
86
+ }
87
+ if (!option) {
88
+ controller?.removeAttribute('aria-activedescendant');
89
+ return;
90
+ }
91
+ option.dataset.ol8Active = 'true';
92
+ controller?.setAttribute('aria-activedescendant', option.id);
93
+ option.scrollIntoView({ block: 'nearest' });
94
+ }
95
+
96
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
97
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Oneli8 · Choice Group (ORGANISM)
3
+ * Figma: Documentation / Organism / Choice Group
4
+ * Checkbox Hierarchy 273:603 Aggregate = None | Some | All
5
+ * Radio Group 859:5180 Behavior = Safe Default | No Default |
6
+ * Horizontal | Invalid
7
+ *
8
+ * First organism in the system, and the first tier allowed to hold logic:
9
+ * the checkbox group computes its parent from its children, and the radio
10
+ * group owns group-level validation.
11
+ *
12
+ * From Figma (273:603): "Aggregate None/Some/All is computed from nested
13
+ * Checkbox molecules; MIXED IS NEVER A THIRD PREFERENCE. Runtime recursion,
14
+ * parent-child synchronization, group naming, required behavior, reset, and
15
+ * announcements live in code."
16
+ *
17
+ * So Aggregate is an OUTPUT, never an input. Nothing may author it.
18
+ *
19
+ * The two shells differ, deliberately:
20
+ * checkbox bordered, radius-container (24px), legend Title/Small
21
+ * radio unbordered, radius-standard (12px), legend Body/Medium
22
+ */
23
+
24
+ .ol8-choice-group {
25
+ box-sizing: border-box;
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: var(--ol8-spacing-stack-related);
29
+ padding: var(--ol8-spacing-inset-standard);
30
+ background: var(--ol8-color-surface-raised);
31
+ border: 0;
32
+ border-radius: var(--ol8-shape-radius-standard);
33
+ font-family: var(--ol8-font-family-functional);
34
+ font-variation-settings: var(--ol8-font-variation-functional);
35
+ min-inline-size: 0; /* fieldset defaults to min-content; this lets it shrink */
36
+ }
37
+
38
+ .ol8-choice-group--checkbox {
39
+ border: 1px solid var(--ol8-color-border-subtle);
40
+ border-radius: var(--ol8-shape-radius-container);
41
+ }
42
+
43
+ /* <legend> is taken out of flow by default, so it is laid out as a normal
44
+ flex child instead — the group label is a real legend for assistive tech. */
45
+ .ol8-choice-group__legend {
46
+ display: block;
47
+ float: none;
48
+ padding: 0;
49
+ inline-size: 100%;
50
+ color: var(--ol8-color-text-primary);
51
+ }
52
+ .ol8-choice-group--checkbox > .ol8-choice-group__legend {
53
+ font-size: var(--ol8-font-size-021);
54
+ line-height: var(--ol8-font-line-030);
55
+ font-weight: var(--ol8-font-weight-semibold);
56
+ }
57
+ .ol8-choice-group--radio > .ol8-choice-group__legend {
58
+ font-size: var(--ol8-font-size-016);
59
+ line-height: var(--ol8-font-line-024);
60
+ font-weight: var(--ol8-font-weight-regular);
61
+ }
62
+
63
+ .ol8-choice-group__instruction {
64
+ font-size: var(--ol8-font-size-012);
65
+ line-height: var(--ol8-font-line-018);
66
+ letter-spacing: 0.12px;
67
+ color: var(--ol8-color-text-secondary);
68
+ }
69
+
70
+ .ol8-choice-group__options { display: flex; flex-direction: column; }
71
+ .ol8-choice-group[data-ol8-orientation="horizontal"] > .ol8-choice-group__options {
72
+ flex-direction: row;
73
+ gap: var(--ol8-spacing-stack-related);
74
+ flex-wrap: wrap;
75
+ }
76
+
77
+ /* Children of a hierarchy sit under their parent, indented one 24px step. */
78
+ .ol8-choice-group__children {
79
+ display: flex;
80
+ flex-direction: column;
81
+ padding-inline-start: var(--ol8-dimension-scale-024);
82
+ }
83
+
84
+ /* Group-level validation message. */
85
+ .ol8-choice-group__message {
86
+ font-size: var(--ol8-font-size-012);
87
+ line-height: var(--ol8-font-line-018);
88
+ letter-spacing: 0.12px;
89
+ color: var(--ol8-color-text-secondary);
90
+ }
91
+ .ol8-choice-group[data-ol8-invalid="true"] > .ol8-choice-group__message {
92
+ color: var(--ol8-color-feedbackcritical-inlinecontent);
93
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Choice Group (ORGANISM) — headless behavior. Aggregate None/Some/All is
3
+ * computed from nested checkboxes; Mixed is never a third preference. The radio
4
+ * group needs almost nothing: native radios already give roving focus, single
5
+ * selection and form submission.
6
+ */
7
+ import type { Ol8ChoiceSize } from '../../molecules/choice-item/choice-item.js';
8
+
9
+ export type Ol8Aggregate = 'none' | 'some' | 'all';
10
+ export declare const OL8_AGGREGATE: readonly Ol8Aggregate[];
11
+
12
+ /** Disabled children are excluded from the tally. */
13
+ export declare function computeAggregate(
14
+ children: Iterable<HTMLInputElement> | ArrayLike<HTMLInputElement>,
15
+ ): Ol8Aggregate;
16
+
17
+ interface Ol8ChoiceGroupShell {
18
+ legend: string;
19
+ instruction?: string;
20
+ message?: string;
21
+ invalid?: boolean;
22
+ disabled?: boolean;
23
+ size?: Ol8ChoiceSize;
24
+ material?: 'regular' | 'gem';
25
+ id?: string;
26
+ className?: string;
27
+ }
28
+
29
+ export interface Ol8RadioOption {
30
+ label: string;
31
+ value: string;
32
+ description?: string;
33
+ disabled?: boolean;
34
+ }
35
+
36
+ export interface Ol8RadioGroupConfig extends Ol8ChoiceGroupShell {
37
+ /** Required — it is what makes the radios a group. */
38
+ name: string;
39
+ options: Ol8RadioOption[];
40
+ /** The selected option's value. Omit for no default. */
41
+ value?: string;
42
+ orientation?: 'vertical' | 'horizontal';
43
+ required?: boolean;
44
+ }
45
+
46
+ export interface Ol8CheckboxOption {
47
+ label: string;
48
+ value: string;
49
+ /** Checkboxes are independent, so each carries its own state. */
50
+ checked?: boolean;
51
+ description?: string;
52
+ disabled?: boolean;
53
+ }
54
+
55
+ export interface Ol8CheckboxGroupConfig extends Ol8ChoiceGroupShell {
56
+ /** Required — the hierarchy's parent row. */
57
+ parentLabel: string;
58
+ options: Ol8CheckboxOption[];
59
+ /** Shared input name for the children. Optional: checkboxes need no group name. */
60
+ name?: string;
61
+ }
62
+
63
+ /**
64
+ * Native semantics do the work; this only builds the shell.
65
+ * @throws if legend, name or options are missing.
66
+ */
67
+ export declare function renderRadioGroup(config: Ol8RadioGroupConfig): string;
68
+
69
+ /**
70
+ * Checkbox hierarchy: one parent whose state is computed from its children. The
71
+ * parent renders unchecked; hydration derives its real state.
72
+ * @throws if legend, parentLabel or options are missing.
73
+ */
74
+ export declare function renderCheckboxGroup(config: Ol8CheckboxGroupConfig): string;
75
+
76
+ /**
77
+ * Wires every checkbox hierarchy under `root`. Idempotent.
78
+ * Radio groups need no wiring — the browser already owns their behaviour.
79
+ */
80
+ export declare function hydrateChoiceGroups(root?: ParentNode): void;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Oneli8 · Choice Group (ORGANISM) — headless behavior.
3
+ *
4
+ * Figma (273:603): "Aggregate None/Some/All is computed from nested Checkbox
5
+ * molecules; Mixed is never a third preference. Runtime recursion,
6
+ * parent-child synchronization, group naming, required behavior, reset, and
7
+ * announcements live in code."
8
+ *
9
+ * Two consequences drive this module:
10
+ *
11
+ * 1. Aggregate is an OUTPUT. Nothing may author it. `computeAggregate` is the
12
+ * single source, and the parent's indeterminate flag is derived from it.
13
+ * 2. Mixed is never a preference a user can choose. Clicking a Mixed parent
14
+ * resolves the whole subtree to checked — it never cycles back through
15
+ * Mixed.
16
+ *
17
+ * The radio group needs almost nothing: a native radio group already gives
18
+ * arrow-key roving focus, single selection and form submission. Adding JS for
19
+ * those would only break them.
20
+ */
21
+ import { renderChoiceItem, syncIndicator, setChoiceMixed } from '../../molecules/choice-item/choice-item.js';
22
+
23
+ export const OL8_AGGREGATE = ['none', 'some', 'all'];
24
+
25
+ /** @returns {'none'|'some'|'all'} */
26
+ export function computeAggregate(children) {
27
+ const boxes = [...children].filter(c => !c.disabled);
28
+ if (boxes.length === 0) return 'none';
29
+ const checked = boxes.filter(c => c.checked).length;
30
+ if (checked === 0) return 'none';
31
+ return checked === boxes.length ? 'all' : 'some';
32
+ }
33
+
34
+ function shell({ kind, legend, instruction, message, invalid, orientation, id, className, body }) {
35
+ const uid = id ?? `ol8-group-${Math.random().toString(36).slice(2, 9)}`;
36
+ const instrId = instruction ? `${uid}-instr` : null;
37
+ const msgId = message ? `${uid}-msg` : null;
38
+ const described = [instrId, msgId].filter(Boolean).join(' ');
39
+
40
+ const attrs = [
41
+ `class="ol8-choice-group ol8-choice-group--${kind}${className ? ` ${className}` : ''}"`,
42
+ `id="${uid}"`,
43
+ `data-ol8-kind="${kind}"`,
44
+ orientation === 'horizontal' ? 'data-ol8-orientation="horizontal"' : '',
45
+ invalid ? 'data-ol8-invalid="true" aria-invalid="true"' : '',
46
+ described ? `aria-describedby="${described}"` : '',
47
+ ].filter(Boolean).join(' ');
48
+
49
+ return `<fieldset ${attrs}>` +
50
+ `<legend class="ol8-choice-group__legend">${escapeText(legend)}</legend>` +
51
+ (instruction ? `<p class="ol8-choice-group__instruction" id="${instrId}">${escapeText(instruction)}</p>` : '') +
52
+ body +
53
+ (message ? `<p class="ol8-choice-group__message" id="${msgId}"${invalid ? ' role="alert"' : ''}>${escapeText(message)}</p>` : '') +
54
+ `</fieldset>`;
55
+ }
56
+
57
+ /**
58
+ * Radio group. Native semantics do the work; this only builds the shell.
59
+ * @param {{legend:string,name:string,options:Array<{label:string,value:string,description?:string,disabled?:boolean}>,
60
+ * value?:string,instruction?:string,message?:string,invalid?:boolean,disabled?:boolean,
61
+ * orientation?:'vertical'|'horizontal',size?:string,material?:string,required?:boolean,id?:string,className?:string}} config
62
+ */
63
+ export function renderRadioGroup(config) {
64
+ const { legend, name, options, value, instruction, message, invalid = false, disabled = false,
65
+ orientation = 'vertical', size = 'standard', material = 'regular', required = false,
66
+ id, className } = config;
67
+ if (!legend) throw new Error('[ol8] a radio group requires a legend');
68
+ if (!name) throw new Error('[ol8] a radio group requires a name — it is what makes it a group');
69
+ if (!options?.length) throw new Error('[ol8] a radio group requires options');
70
+
71
+ const items = options.map(o => renderChoiceItem('radio', o.label, {
72
+ size, name, value: o.value, material, required,
73
+ checked: value !== undefined && o.value === value,
74
+ disabled: disabled || !!o.disabled,
75
+ invalid, description: o.description,
76
+ })).join('');
77
+
78
+ return shell({ kind: 'radio', legend, instruction, message, invalid, orientation, id, className,
79
+ body: `<div class="ol8-choice-group__options">${items}</div>` });
80
+ }
81
+
82
+ /**
83
+ * Checkbox hierarchy: one parent whose state is computed from its children.
84
+ * The parent is rendered unchecked; hydration derives its real state.
85
+ */
86
+ export function renderCheckboxGroup(config) {
87
+ const { legend, parentLabel, options, instruction, message, invalid = false, disabled = false,
88
+ size = 'standard', material = 'regular', name, id, className } = config;
89
+ if (!legend) throw new Error('[ol8] a checkbox group requires a legend');
90
+ if (!parentLabel) throw new Error('[ol8] a checkbox hierarchy requires a parent label');
91
+ if (!options?.length) throw new Error('[ol8] a checkbox group requires options');
92
+
93
+ const parent = renderChoiceItem('checkbox', parentLabel, {
94
+ size, material, disabled, invalid, className: 'ol8-choice-group__parent',
95
+ });
96
+ const children = options.map(o => renderChoiceItem('checkbox', o.label, {
97
+ size, material, name, value: o.value, checked: !!o.checked,
98
+ disabled: disabled || !!o.disabled, description: o.description,
99
+ className: 'ol8-choice-group__child',
100
+ })).join('');
101
+
102
+ return shell({ kind: 'checkbox', legend, instruction, message, invalid, orientation: 'vertical',
103
+ id, className,
104
+ body: parent + `<div class="ol8-choice-group__children">${children}</div>` });
105
+ }
106
+
107
+ /**
108
+ * Wires every checkbox hierarchy under `root`. Idempotent.
109
+ * Radio groups need no wiring — the browser already owns their behaviour.
110
+ */
111
+ export function hydrateChoiceGroups(root = document) {
112
+ const groups = [...root.querySelectorAll('.ol8-choice-group--checkbox')];
113
+
114
+ for (const group of groups) {
115
+ const parentRow = group.querySelector(':scope > .ol8-choice-group__parent');
116
+ const parent = parentRow?.querySelector(':scope > .ol8-choice__input');
117
+ const children = [...group.querySelectorAll('.ol8-choice-group__child > .ol8-choice__input')];
118
+ if (!parent || children.length === 0) continue;
119
+
120
+ applyAggregate(group, parentRow, parent, children);
121
+
122
+ if (group.dataset.ol8GroupBound === 'true') continue;
123
+
124
+ // Mixed is never a preference: a Mixed parent resolves to all-checked.
125
+ parent.addEventListener('change', () => {
126
+ const target = parent.indeterminate ? true : parent.checked;
127
+ for (const child of children) {
128
+ if (child.disabled) continue;
129
+ child.checked = target;
130
+ syncIndicator(child.closest('.ol8-choice'));
131
+ }
132
+ applyAggregate(group, parentRow, parent, children);
133
+ group.dispatchEvent(new CustomEvent('ol8:aggregatechange', {
134
+ bubbles: true, detail: { aggregate: computeAggregate(children) },
135
+ }));
136
+ });
137
+
138
+ for (const child of children) {
139
+ child.addEventListener('change', () => {
140
+ applyAggregate(group, parentRow, parent, children);
141
+ group.dispatchEvent(new CustomEvent('ol8:aggregatechange', {
142
+ bubbles: true, detail: { aggregate: computeAggregate(children) },
143
+ }));
144
+ });
145
+ }
146
+ group.dataset.ol8GroupBound = 'true';
147
+ }
148
+ return groups.length;
149
+ }
150
+
151
+ function applyAggregate(group, parentRow, parent, children) {
152
+ const aggregate = computeAggregate(children);
153
+ group.dataset.ol8Aggregate = aggregate;
154
+ parent.checked = aggregate === 'all';
155
+ setChoiceMixed(parentRow, aggregate === 'some');
156
+ syncIndicator(parentRow);
157
+ }
158
+
159
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './choice-group.js';
@@ -0,0 +1 @@
1
+ export * from './choice-group.js';
@@ -0,0 +1 @@
1
+ export * from './choice-group/index.js';
@@ -0,0 +1 @@
1
+ export * from './choice-group/index.js';
package/styles.css ADDED
@@ -0,0 +1,13 @@
1
+ /* OneLi8 · complete stylesheet for @oneli8/core.
2
+ Load order matters: tokens define the custom properties every rule reads,
3
+ atoms before the molecules that compose them, and the Gem material last
4
+ because it adapts surfaces the component rules have already painted. */
5
+ @import "@oneli8/tokens/css";
6
+ @import "./src/atoms/icon/icon.css";
7
+ @import "./src/atoms/selection-indicator/selection-indicator.css";
8
+ @import "./src/molecules/button/button.css";
9
+ @import "./src/molecules/icon-button/icon-button.css";
10
+ @import "./src/molecules/choice-item/choice-item.css";
11
+ @import "./src/molecules/selection-option/selection-option.css";
12
+ @import "./src/organisms/choice-group/choice-group.css";
13
+ @import "./src/materials/gem/gem.css";