@oneli8/react 1.0.0-beta.2 → 1.0.0-beta.4

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/AGENTS.md +12 -0
  2. package/PRINCIPLES.md +48 -0
  3. package/README.md +61 -75
  4. package/ai-context.json +146 -0
  5. package/package.json +30 -24
  6. package/skills/oneli8-figma-to-code/SKILL.md +25 -0
  7. package/skills/oneli8-icons/SKILL.md +24 -0
  8. package/skills/oneli8-ui/SKILL.md +24 -0
  9. package/src/atoms/Icon.js +31 -29
  10. package/src/atoms/SelectionIndicator.js +43 -0
  11. package/src/foundations/geometry.js +72 -0
  12. package/src/foundations/icons.generated.d.ts +38 -0
  13. package/src/foundations/icons.generated.js +406 -0
  14. package/src/index.d.ts +167 -4
  15. package/src/index.js +9 -4
  16. package/src/molecules/Button.js +47 -18
  17. package/src/molecules/ChoiceItem.js +55 -0
  18. package/src/molecules/IconButton.js +31 -16
  19. package/src/molecules/SelectionOption.js +41 -0
  20. package/src/organisms/ChoiceGroup.js +78 -0
  21. package/src/styles/button.css +195 -0
  22. package/src/styles/choice-group.css +95 -0
  23. package/src/styles/choice-item.css +192 -0
  24. package/src/styles/gem.css +173 -0
  25. package/src/styles/icon-button.css +224 -0
  26. package/src/styles/icon.css +56 -0
  27. package/src/styles/selection-indicator.css +153 -0
  28. package/src/styles/selection-option.css +123 -0
  29. package/styles.css +12 -6
  30. package/src/Button.d.ts +0 -20
  31. package/src/Button.js +0 -1
  32. package/src/Icon.d.ts +0 -4
  33. package/src/Icon.js +0 -1
  34. package/src/IconButton.d.ts +0 -20
  35. package/src/IconButton.js +0 -1
  36. package/src/Link.d.ts +0 -16
  37. package/src/Link.js +0 -1
  38. package/src/atoms/AnchorControl.js +0 -7
  39. package/src/atoms/BusyIndicator.js +0 -6
  40. package/src/atoms/ButtonControl.js +0 -14
  41. package/src/atoms/IconFrame.js +0 -19
  42. package/src/atoms/Label.js +0 -5
  43. package/src/foundations/collection.js +0 -13
  44. package/src/foundations/runtime.js +0 -44
  45. package/src/molecules/Link.js +0 -14
  46. package/styles/accessibility.css +0 -1
  47. package/styles/button.css +0 -176
  48. package/styles/gem-control-focus.css +0 -33
  49. package/styles/icon-button.css +0 -42
  50. package/styles/icon-frame.css +0 -1
  51. package/styles/link.css +0 -20
package/src/index.d.ts CHANGED
@@ -1,4 +1,167 @@
1
- export * from "./Button.js";
2
- export * from "./IconButton.js";
3
- export * from "./Link.js";
4
- export * from "./Icon.js";
1
+ import type {
2
+ ComponentPropsWithoutRef, ForwardRefExoticComponent, ReactElement, ReactNode, RefAttributes,
3
+ } from 'react';
4
+ import type { Ol8IconName } from './foundations/icons.generated.js';
5
+
6
+ export type { Ol8IconName };
7
+ export { OL8_ICONS, OL8_ICON_VIEWBOX } from './foundations/icons.generated.js';
8
+
9
+ export type Ol8Variant = 'primary' | 'secondary' | 'quiet' | 'destructive';
10
+ export type Ol8Size = 'compact' | 'standard' | 'comfortable' | 'large';
11
+ export type Ol8Material = 'regular' | 'gem';
12
+ export type Ol8SelectionKind = 'checkbox' | 'radio' | 'switch';
13
+ export type Ol8SelectionSize = 'compact' | 'comfortable';
14
+ export type Ol8Selection =
15
+ | 'unchecked' | 'checked' | 'mixed'
16
+ | 'unselected' | 'selected'
17
+ | 'off' | 'on';
18
+ export type Ol8OptionSize = 'standard' | 'large';
19
+ export type Ol8Aggregate = 'none' | 'some' | 'all';
20
+
21
+ export declare const OL8_BUTTON_VARIANTS: readonly Ol8Variant[];
22
+ export declare const OL8_BUTTON_SIZES: readonly Ol8Size[];
23
+ export declare const OL8_MATERIALS: readonly Ol8Material[];
24
+ /** Gem is approved for Primary and Secondary only (Figma 100:3). */
25
+ export declare const GEM_VARIANTS: readonly ('primary' | 'secondary')[];
26
+ export declare const OL8_ICON_BUTTON_VARIANTS: readonly Ol8Variant[];
27
+ export declare const OL8_ICON_BUTTON_SIZES: readonly Ol8Size[];
28
+ export declare const OL8_ICON_BUTTON_SHAPES: readonly ('rounded' | 'circle')[];
29
+ export declare const OL8_SELECTION_KINDS: readonly Ol8SelectionKind[];
30
+ export declare const OL8_SELECTION_SIZES: readonly Ol8SelectionSize[];
31
+ export declare const OL8_SELECTION_STATES: Record<Ol8SelectionKind, readonly Ol8Selection[]>;
32
+ export declare const OL8_CHOICE_KINDS: readonly Ol8SelectionKind[];
33
+ export declare const OL8_CHOICE_SIZES: readonly Ol8Size[];
34
+ export declare const OL8_OPTION_SIZES: readonly Ol8OptionSize[];
35
+ export declare const OL8_AGGREGATE: readonly Ol8Aggregate[];
36
+
37
+ export declare function isOl8IconName(name: string): name is Ol8IconName;
38
+ export declare function iconSizeForButton(size: Ol8Size): 18 | 24;
39
+ export declare function artworkSizeForIconButton(size: Ol8Size): 18 | 24;
40
+ export declare function indicatorSizeFor(size: Ol8Size): Ol8SelectionSize;
41
+ export declare function selectionStateFor(
42
+ kind: Ol8SelectionKind, state?: { checked?: boolean; mixed?: boolean }
43
+ ): Ol8Selection;
44
+ export declare function computeAggregate(
45
+ children: Iterable<{ checked?: boolean; disabled?: boolean }>
46
+ ): Ol8Aggregate;
47
+
48
+ /** ATOM. Decorative unless given a label; the parent control carries meaning. */
49
+ export interface IconProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children'> {
50
+ name: Ol8IconName;
51
+ size?: number;
52
+ /** Promotes the icon to role="img". Omit for decorative. */
53
+ label?: string;
54
+ }
55
+ export declare function Icon(props: IconProps): ReactElement;
56
+
57
+ /** ATOM. Presentational only; the native input in ChoiceItem owns state and focus. */
58
+ export interface SelectionIndicatorProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children'> {
59
+ kind: Ol8SelectionKind;
60
+ selection?: Ol8Selection;
61
+ size?: Ol8SelectionSize;
62
+ disabled?: boolean;
63
+ /** Switch thumb artwork only. */
64
+ icon?: Ol8IconName;
65
+ }
66
+ export declare function SelectionIndicator(props: SelectionIndicatorProps): ReactElement;
67
+
68
+ /** MOLECULE. States are CSS, never classes. */
69
+ export interface ButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'type'> {
70
+ children?: ReactNode;
71
+ variant?: Ol8Variant;
72
+ size?: Ol8Size;
73
+ /** Throws for quiet and destructive: Gem has no approved treatment there. */
74
+ material?: Ol8Material;
75
+ leadingIcon?: Ol8IconName;
76
+ trailingIcon?: Ol8IconName;
77
+ loading?: boolean;
78
+ pressed?: boolean;
79
+ fullWidth?: boolean;
80
+ /** Renders an anchor instead of a button. */
81
+ href?: string;
82
+ type?: 'button' | 'submit' | 'reset';
83
+ /** Keeps the leading icon beside the spinner, Figma's literal reading. */
84
+ keepLeadingIconWhileLoading?: boolean;
85
+ }
86
+ export declare const Button: ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLElement>>;
87
+
88
+ /** MOLECULE. An accessible name is mandatory and enforced at runtime. */
89
+ export interface IconButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'type'> {
90
+ icon: Ol8IconName;
91
+ accessibleName: string;
92
+ variant?: Ol8Variant;
93
+ size?: Ol8Size;
94
+ shape?: 'rounded' | 'circle';
95
+ material?: Ol8Material;
96
+ loading?: boolean;
97
+ pressed?: boolean;
98
+ type?: 'button' | 'submit' | 'reset';
99
+ }
100
+ export declare const IconButton: ForwardRefExoticComponent<IconButtonProps & RefAttributes<HTMLButtonElement>>;
101
+
102
+ /** MOLECULE. A real input owns state, focus and form participation. */
103
+ export interface ChoiceItemProps extends Omit<ComponentPropsWithoutRef<'input'>, 'size' | 'type' | 'children'> {
104
+ kind: Ol8SelectionKind;
105
+ children?: ReactNode;
106
+ size?: Ol8Size;
107
+ /** Checkbox only. Sets the JavaScript-only indeterminate flag. */
108
+ mixed?: boolean;
109
+ invalid?: boolean;
110
+ /** Associated with aria-describedby, outside the accessible name. */
111
+ description?: ReactNode;
112
+ material?: Ol8Material;
113
+ }
114
+ export declare const ChoiceItem: ForwardRefExoticComponent<ChoiceItemProps & RefAttributes<HTMLInputElement>>;
115
+
116
+ /** MOLECULE. Never focusable; a listbox moves a virtual cursor instead. */
117
+ export interface SelectionOptionProps extends Omit<ComponentPropsWithoutRef<'li'>, 'children'> {
118
+ children?: ReactNode;
119
+ value?: string;
120
+ size?: Ol8OptionSize;
121
+ selected?: boolean;
122
+ active?: boolean;
123
+ disabled?: boolean;
124
+ description?: ReactNode;
125
+ leadingIcon?: Ol8IconName;
126
+ focusRing?: boolean;
127
+ }
128
+ export declare function SelectionOption(props: SelectionOptionProps): ReactElement;
129
+
130
+ export interface Ol8GroupOption {
131
+ label: ReactNode;
132
+ value: string;
133
+ description?: ReactNode;
134
+ disabled?: boolean;
135
+ /** CheckboxGroup only; radios take a single group value. */
136
+ checked?: boolean;
137
+ }
138
+ interface Ol8GroupShell {
139
+ legend: ReactNode;
140
+ instruction?: ReactNode;
141
+ message?: ReactNode;
142
+ invalid?: boolean;
143
+ disabled?: boolean;
144
+ size?: Ol8Size;
145
+ material?: Ol8Material;
146
+ id?: string;
147
+ className?: string;
148
+ }
149
+ /** ORGANISM. Native radios already give roving focus and single selection. */
150
+ export interface RadioGroupProps extends Ol8GroupShell {
151
+ name: string;
152
+ options: Ol8GroupOption[];
153
+ value?: string;
154
+ onChange?: (value: string) => void;
155
+ orientation?: 'vertical' | 'horizontal';
156
+ required?: boolean;
157
+ }
158
+ export declare function RadioGroup(props: RadioGroupProps): ReactElement;
159
+
160
+ /** ORGANISM. Aggregate is computed; Mixed is never a third preference. */
161
+ export interface CheckboxGroupProps extends Ol8GroupShell {
162
+ parentLabel: ReactNode;
163
+ options: Ol8GroupOption[];
164
+ name?: string;
165
+ onChange?: (state: Record<string, boolean>) => void;
166
+ }
167
+ export declare function CheckboxGroup(props: CheckboxGroupProps): ReactElement;
package/src/index.js CHANGED
@@ -1,4 +1,9 @@
1
- export {Button} from "./Button.js";
2
- export {IconButton} from "./IconButton.js";
3
- export {Link} from "./Link.js";
4
- export {Icon,iconNames} from "./Icon.js";
1
+ export { Icon } from './atoms/Icon.js';
2
+ export { SelectionIndicator } from './atoms/SelectionIndicator.js';
3
+ export { Button } from './molecules/Button.js';
4
+ export { IconButton } from './molecules/IconButton.js';
5
+ export { ChoiceItem } from './molecules/ChoiceItem.js';
6
+ export { SelectionOption } from './molecules/SelectionOption.js';
7
+ export { RadioGroup, CheckboxGroup } from './organisms/ChoiceGroup.js';
8
+ export * from './foundations/geometry.js';
9
+ export { OL8_ICONS, OL8_ICON_VIEWBOX } from './foundations/icons.generated.js';
@@ -1,21 +1,50 @@
1
- import {createElement,forwardRef} from "react";
2
- import {BusyIndicator} from "../atoms/BusyIndicator.js";
3
- import {ButtonControl} from "../atoms/ButtonControl.js";
4
- import {IconFrame,iconFrameSizeForControl} from "../atoms/IconFrame.js";
1
+ import { createElement, forwardRef } from 'react';
2
+ import { Icon, IconGlyph } from '../atoms/Icon.js';
3
+ import { assertVariant, iconSizeForButton } from '../foundations/geometry.js';
5
4
 
6
- const motions=new Set(["none","rotate","forward","backward"]);
5
+ /**
6
+ * MOLECULE. Figma's six States are visual evidence for review, not classes.
7
+ * In code they are :hover, :active, :focus-visible, [disabled] and
8
+ * [data-ol8-loading]. There is no .ol8-btn--hover.
9
+ */
10
+ export const Button = forwardRef(function Button({
11
+ children, variant = 'primary', size = 'standard', material = 'regular',
12
+ leadingIcon, trailingIcon, loading = false, disabled = false, pressed,
13
+ fullWidth = false, href, type = 'button', className = '',
14
+ keepLeadingIconWhileLoading = false, ...rest
15
+ }, ref) {
16
+ assertVariant(variant, size, material);
17
+ const isLink = typeof href === 'string';
18
+ const inert = disabled || loading;
19
+ const iconSize = iconSizeForButton(size);
7
20
 
8
- export const Button=forwardRef(function Button({
9
- children,variant="primary",size="comfortable",material="regular",loading=false,disabled=false,pressed,
10
- leadingIcon,trailingIcon,iconMotion="none",fullWidth=false,className="",type="button",onClick,...rest
11
- },ref){
12
- const resolvedMotion=motions.has(iconMotion)?iconMotion:"none";
13
- const iconSize=iconFrameSizeForControl(size);
14
- const content=[];
15
- if(leadingIcon)content.push(createElement(IconFrame,{className:"ol8-Button__icon",size:iconSize,key:"leading"},leadingIcon));
16
- if(loading)content.push(createElement(BusyIndicator,{className:"ol8-Button__status",key:"status"}));
17
- content.push(createElement("span",{className:"ol8-Button__label",key:"label"},children));
18
- if(trailingIcon)content.push(createElement(IconFrame,{className:"ol8-Button__icon",size:iconSize,key:"trailing"},trailingIcon));
19
- return createElement(ButtonControl,{...rest,ref,type,disabled,loading,pressed,variant,size,material,className,baseClass:"ol8-Button",onClick,
20
- "data-ol8-icon-motion":resolvedMotion,"data-ol8-full-width":fullWidth||undefined},content);
21
+ // The spinner takes the leading slot by default, so the control never shows
22
+ // two competing leading glyphs and its width stays predictable.
23
+ const content = [];
24
+ if (loading) {
25
+ if (keepLeadingIconWhileLoading && leadingIcon) {
26
+ content.push(createElement(Icon, { key: 'leading', name: leadingIcon, size: iconSize }));
27
+ }
28
+ content.push(createElement('span', { key: 'spinner', className: 'ol8-btn__spinner ol8-icon', 'aria-hidden': 'true' },
29
+ createElement(IconGlyph, { name: 'loading' })));
30
+ } else if (leadingIcon) {
31
+ content.push(createElement(Icon, { key: 'leading', name: leadingIcon, size: iconSize }));
32
+ }
33
+ content.push(createElement('span', { key: 'label', className: 'ol8-btn__label' }, children));
34
+ if (trailingIcon) content.push(createElement(Icon, { key: 'trailing', name: trailingIcon, size: iconSize }));
35
+
36
+ const shared = {
37
+ ...rest, ref,
38
+ className: `ol8-btn ol8-btn--${variant}${className ? ` ${className}` : ''}`,
39
+ 'data-ol8-size': size,
40
+ ...(loading ? { 'data-ol8-loading': 'true', 'aria-busy': 'true' } : {}),
41
+ ...(pressed !== undefined ? { 'aria-pressed': String(pressed) } : {}),
42
+ ...(fullWidth ? { 'data-ol8-fullwidth': 'true' } : {}),
43
+ ...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
44
+ ...(loading && keepLeadingIconWhileLoading ? { 'data-ol8-loading-keep-icon': 'true' } : {}),
45
+ };
46
+ // A link cannot be natively disabled, so it leaves the tab order instead.
47
+ return isLink
48
+ ? createElement('a', { ...shared, ...(inert ? { role: 'button', 'aria-disabled': 'true', tabIndex: -1 } : { href }) }, content)
49
+ : createElement('button', { ...shared, type, ...(inert ? { disabled: true } : {}) }, content);
21
50
  });
@@ -0,0 +1,55 @@
1
+ import { createElement, forwardRef, useId } from 'react';
2
+ import { SelectionIndicator } from '../atoms/SelectionIndicator.js';
3
+ import { OL8_CHOICE_KINDS, OL8_CHOICE_SIZES, indicatorSizeFor, selectionStateFor } from '../foundations/geometry.js';
4
+
5
+ /**
6
+ * MOLECULE. A real input owns state, focus and form participation; this never
7
+ * reimplements toggling. `indeterminate` is the one thing markup cannot express,
8
+ * so React sets it through a ref callback rather than an attribute.
9
+ */
10
+ export const ChoiceItem = forwardRef(function ChoiceItem({
11
+ kind, children, size = 'standard', checked, defaultChecked, mixed = false,
12
+ disabled = false, invalid = false, description, name, value, required = false,
13
+ material = 'regular', id, className = '', onChange, ...rest
14
+ }, ref) {
15
+ if (!OL8_CHOICE_KINDS.includes(kind)) throw new Error(`[ol8] unknown choice kind "${kind}"`);
16
+ if (!OL8_CHOICE_SIZES.includes(size)) throw new Error(`[ol8] unknown choice size "${size}"`);
17
+ const auto = useId();
18
+ const rowId = id ?? `ol8-choice-${auto}`;
19
+ const descId = description ? `${rowId}-desc` : undefined;
20
+ const isOn = checked ?? defaultChecked ?? false;
21
+
22
+ return createElement('label', {
23
+ className: `ol8-choice ol8-choice--${kind}${className ? ` ${className}` : ''}`,
24
+ htmlFor: rowId,
25
+ 'data-ol8-size': size,
26
+ 'data-ol8-kind': kind,
27
+ 'data-ol8-availability': disabled ? 'disabled' : 'enabled',
28
+ ...(invalid ? { 'data-ol8-validity': 'invalid' } : {}),
29
+ ...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
30
+ }, [
31
+ createElement('input', {
32
+ key: 'input', ...rest,
33
+ ref: (node) => {
34
+ if (node) node.indeterminate = kind === 'checkbox' && mixed;
35
+ if (typeof ref === 'function') ref(node); else if (ref) ref.current = node;
36
+ },
37
+ type: kind === 'radio' ? 'radio' : 'checkbox',
38
+ className: 'ol8-choice__input', id: rowId, name, value, required, disabled, onChange,
39
+ ...(checked !== undefined ? { checked } : defaultChecked !== undefined ? { defaultChecked } : {}),
40
+ ...(descId ? { 'aria-describedby': descId } : {}),
41
+ ...(invalid ? { 'aria-invalid': 'true' } : {}),
42
+ ...(kind === 'switch' ? { role: 'switch' } : {}),
43
+ }),
44
+ createElement('span', { key: 'surface', className: 'ol8-choice__surface', 'aria-hidden': 'true' }),
45
+ createElement('span', { key: 'indicator', className: 'ol8-choice__indicator' },
46
+ createElement(SelectionIndicator, {
47
+ kind, size: indicatorSizeFor(size), disabled,
48
+ selection: selectionStateFor(kind, { checked: isOn, mixed }),
49
+ })),
50
+ createElement('span', { key: 'text', className: 'ol8-choice__text' }, [
51
+ createElement('span', { key: 'label', className: 'ol8-choice__label' }, children),
52
+ description ? createElement('span', { key: 'desc', className: 'ol8-choice__description', id: descId }, description) : null,
53
+ ]),
54
+ ]);
55
+ });
@@ -1,18 +1,33 @@
1
- import {createElement,forwardRef} from "react";
2
- import {BusyIndicator} from "../atoms/BusyIndicator.js";
3
- import {ButtonControl} from "../atoms/ButtonControl.js";
4
- import {IconFrame,iconFrameSizeForControl} from "../atoms/IconFrame.js";
1
+ import { createElement, forwardRef } from 'react';
2
+ import { Icon } from '../atoms/Icon.js';
3
+ import { assertVariant, artworkSizeForIconButton, OL8_ICON_BUTTON_SHAPES } from '../foundations/geometry.js';
5
4
 
6
- const shapes=new Set(["rounded","circle"]);
7
- const motions=new Set(["system","none"]);
8
-
9
- export const IconButton=forwardRef(function IconButton({icon,accessibleName,variant="quiet",size="comfortable",shape="rounded",material="regular",loading=false,disabled=false,pressed,motion="system",className="",...rest},ref){
10
- if(typeof accessibleName!=="string"||!accessibleName.trim())throw new TypeError("IconButton requires a non-empty accessibleName.");
11
- if(!icon&&!loading)throw new TypeError("IconButton requires icon artwork when it is not loading.");
12
- const iconSize=iconFrameSizeForControl(size,{large:"large"});
13
- const content=loading?createElement(BusyIndicator,{className:"ol8-IconButton__status"}):createElement(IconFrame,{className:"ol8-IconButton__icon",size:iconSize},icon);
14
- return createElement(ButtonControl,{...rest,ref,baseClass:"ol8-IconButton",variant,size,material,loading,disabled,pressed,className,"aria-label":accessibleName,
15
- "data-ol8-shape":shapes.has(shape)?shape:"rounded","data-ol8-motion":motions.has(motion)?motion:"system"},
16
- createElement("span",{className:"ol8-IconButton__surface","aria-hidden":"true"},content)
17
- );
5
+ /**
6
+ * MOLECULE. Two boxes deliberately: the outer element is the protected target
7
+ * and the inner surface is the visible box, so a Compact control still keeps a
8
+ * 48px hit area. An accessible name is mandatory and enforced here.
9
+ */
10
+ export const IconButton = forwardRef(function IconButton({
11
+ icon, accessibleName, variant = 'primary', size = 'standard', shape = 'rounded',
12
+ material = 'regular', loading = false, disabled = false, pressed,
13
+ type = 'button', className = '', ...rest
14
+ }, ref) {
15
+ assertVariant(variant, size, material);
16
+ if (!OL8_ICON_BUTTON_SHAPES.includes(shape)) throw new Error(`[ol8] unknown icon button shape "${shape}"`);
17
+ if (typeof accessibleName !== 'string' || accessibleName.trim() === '') {
18
+ throw new Error('[ol8] IconButton requires accessibleName. An icon alone announces nothing.');
19
+ }
20
+ const inert = disabled || loading;
21
+ return createElement('button', {
22
+ ...rest, ref, type,
23
+ className: `ol8-icon-btn ol8-icon-btn--${variant}${className ? ` ${className}` : ''}`,
24
+ 'data-ol8-size': size,
25
+ 'data-ol8-shape': shape,
26
+ 'aria-label': accessibleName,
27
+ ...(inert ? { disabled: true } : {}),
28
+ ...(loading ? { 'data-ol8-loading': 'true', 'aria-busy': 'true' } : {}),
29
+ ...(pressed !== undefined ? { 'aria-pressed': String(pressed) } : {}),
30
+ ...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
31
+ }, createElement('span', { className: 'ol8-icon-btn__surface' },
32
+ createElement(Icon, { name: loading ? 'loading' : icon, size: artworkSizeForIconButton(size) })));
18
33
  });
@@ -0,0 +1,41 @@
1
+ import { createElement, useId } from 'react';
2
+ import { Icon } from '../atoms/Icon.js';
3
+ import { OL8_OPTION_SIZES } from '../foundations/geometry.js';
4
+
5
+ /**
6
+ * MOLECULE. Never focusable. A listbox moves a virtual cursor with
7
+ * aria-activedescendant while real focus stays on the combobox input, so this
8
+ * carries no tabIndex. Options cannot be natively disabled, hence aria-disabled.
9
+ */
10
+ export function SelectionOption({
11
+ children, value, size = 'standard', selected = false, active = false,
12
+ disabled = false, description, leadingIcon, id, focusRing = false, className = '', ...rest
13
+ }) {
14
+ if (!OL8_OPTION_SIZES.includes(size)) throw new Error(`[ol8] unknown option size "${size}"`);
15
+ const auto = useId();
16
+ const optionId = id ?? `ol8-option-${auto}`;
17
+ const labelId = `${optionId}-label`;
18
+ const descId = description ? `${optionId}-desc` : undefined;
19
+ return createElement('li', {
20
+ ...rest,
21
+ className: `ol8-option${className ? ` ${className}` : ''}`,
22
+ role: 'option', id: optionId,
23
+ 'data-ol8-size': size,
24
+ 'aria-selected': String(selected),
25
+ 'aria-labelledby': labelId,
26
+ ...(descId ? { 'aria-describedby': descId } : {}),
27
+ ...(disabled ? { 'aria-disabled': 'true' } : {}),
28
+ ...(active ? { 'data-ol8-active': 'true' } : {}),
29
+ ...(focusRing ? { 'data-ol8-focus-ring': 'true' } : {}),
30
+ ...(value !== undefined ? { 'data-ol8-value': value } : {}),
31
+ }, createElement('span', { className: 'ol8-option__surface' }, [
32
+ leadingIcon ? createElement('span', { key: 'lead', className: 'ol8-option__leading' },
33
+ createElement(Icon, { name: leadingIcon, size: 18 })) : null,
34
+ createElement('span', { key: 'text', className: 'ol8-option__text' }, [
35
+ createElement('span', { key: 'l', className: 'ol8-option__label', id: labelId }, children),
36
+ description ? createElement('span', { key: 'd', className: 'ol8-option__description', id: descId }, description) : null,
37
+ ]),
38
+ selected ? createElement('span', { key: 'check', className: 'ol8-option__selected' },
39
+ createElement(Icon, { name: 'check', size: 18 })) : null,
40
+ ]));
41
+ }
@@ -0,0 +1,78 @@
1
+ import { createElement, useId, useMemo, useState } from 'react';
2
+ import { ChoiceItem } from '../molecules/ChoiceItem.js';
3
+ import { computeAggregate } from '../foundations/geometry.js';
4
+
5
+ function Shell({ kind, legend, instruction, message, invalid, orientation = 'vertical', id, className, children }) {
6
+ return createElement('fieldset', {
7
+ className: `ol8-choice-group ol8-choice-group--${kind}${className ? ` ${className}` : ''}`,
8
+ id,
9
+ ...(orientation === 'horizontal' ? { 'data-ol8-orientation': 'horizontal' } : {}),
10
+ ...(invalid ? { 'data-ol8-validity': 'invalid' } : {}),
11
+ }, [
12
+ createElement('legend', { key: 'legend', className: 'ol8-choice-group__legend' }, legend),
13
+ instruction ? createElement('p', { key: 'i', className: 'ol8-choice-group__instruction' }, instruction) : null,
14
+ children,
15
+ message ? createElement('p', { key: 'm', className: 'ol8-choice-group__message', ...(invalid ? { role: 'alert' } : {}) }, message) : null,
16
+ ]);
17
+ }
18
+
19
+ /**
20
+ * ORGANISM. Native radios already give roving focus, single selection and form
21
+ * submission, so this only builds the shell around them.
22
+ */
23
+ export function RadioGroup({
24
+ legend, name, options, value, onChange, instruction, message, invalid = false,
25
+ disabled = false, orientation = 'vertical', size = 'standard', material = 'regular',
26
+ required = false, id, className = '',
27
+ }) {
28
+ if (!legend) throw new Error('[ol8] a radio group requires a legend');
29
+ if (!name) throw new Error('[ol8] a radio group requires a name — it is what makes it a group');
30
+ if (!options?.length) throw new Error('[ol8] a radio group requires options');
31
+ return createElement(Shell, { kind: 'radio', legend, instruction, message, invalid, orientation, id, className },
32
+ createElement('div', { key: 'options', className: 'ol8-choice-group__options' },
33
+ options.map((o) => createElement(ChoiceItem, {
34
+ key: o.value, kind: 'radio', name, value: o.value, size, material, required, invalid,
35
+ description: o.description, disabled: disabled || !!o.disabled,
36
+ checked: value !== undefined ? o.value === value : undefined,
37
+ onChange: onChange ? () => onChange(o.value) : undefined,
38
+ }, o.label))));
39
+ }
40
+
41
+ /**
42
+ * ORGANISM. Aggregate None/Some/All is computed from the children; Mixed is
43
+ * never a third preference a user can choose. Toggling the parent resolves the
44
+ * whole subtree rather than cycling back through Mixed.
45
+ */
46
+ export function CheckboxGroup({
47
+ legend, parentLabel, options, name, onChange, instruction, message, invalid = false,
48
+ disabled = false, size = 'standard', material = 'regular', id, className = '',
49
+ }) {
50
+ if (!legend) throw new Error('[ol8] a checkbox group requires a legend');
51
+ if (!parentLabel) throw new Error('[ol8] a checkbox hierarchy requires a parent label');
52
+ if (!options?.length) throw new Error('[ol8] a checkbox group requires options');
53
+ const auto = useId();
54
+ const [state, setState] = useState(() => Object.fromEntries(options.map((o) => [o.value, !!o.checked])));
55
+ const aggregate = useMemo(
56
+ () => computeAggregate(options.map((o) => ({ checked: state[o.value], disabled: disabled || !!o.disabled }))),
57
+ [state, options, disabled]
58
+ );
59
+ const setAll = (next) => {
60
+ const updated = Object.fromEntries(options.map((o) => [o.value, (disabled || o.disabled) ? state[o.value] : next]));
61
+ setState(updated); onChange?.(updated);
62
+ };
63
+ return createElement(Shell, { kind: 'checkbox', legend, instruction, message, invalid, id: id ?? `ol8-group-${auto}`, className }, [
64
+ createElement(ChoiceItem, {
65
+ key: 'parent', kind: 'checkbox', size, material, disabled, invalid,
66
+ className: 'ol8-choice-group__parent',
67
+ checked: aggregate === 'all', mixed: aggregate === 'some',
68
+ onChange: () => setAll(aggregate !== 'all'),
69
+ }, parentLabel),
70
+ createElement('div', { key: 'children', className: 'ol8-choice-group__children' },
71
+ options.map((o) => createElement(ChoiceItem, {
72
+ key: o.value, kind: 'checkbox', name, value: o.value, size, material,
73
+ description: o.description, disabled: disabled || !!o.disabled,
74
+ className: 'ol8-choice-group__child', checked: !!state[o.value],
75
+ onChange: () => { const u = { ...state, [o.value]: !state[o.value] }; setState(u); onChange?.(u); },
76
+ }, o.label))),
77
+ ]);
78
+ }