@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +76 -0
- package/package.json +79 -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 +38 -0
- package/src/atoms/icon/icons.generated.js +128 -0
- package/src/atoms/icon/index.d.ts +2 -0
- package/src/atoms/icon/index.js +2 -0
- package/src/atoms/index.d.ts +2 -0
- package/src/atoms/index.js +2 -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 +140 -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 +171 -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-item/choice-item.css +190 -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/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 +4 -0
- package/src/molecules/index.js +4 -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/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/index.d.ts +1 -0
- package/src/organisms/index.js +1 -0
- 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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
97
|
+
function escapeText(v) { return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
@@ -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";
|