cursedbelt 5.103.1 → 5.104.0
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/dist/react/components/ChoiceGroup.d.ts +16 -3
- package/dist/react/components/ChoiceGroup.d.ts.map +1 -1
- package/dist/react/components/ChoiceGroup.js +27 -5
- package/dist/react/components/ChoiceGroup.js.map +1 -1
- package/package.json +1 -1
- package/src/react/components/ChoiceGroup.spec.tsx +61 -0
- package/src/react/components/ChoiceGroup.tsx +83 -13
|
@@ -16,6 +16,10 @@ export interface ChoiceOption<T extends string = string> {
|
|
|
16
16
|
image?: ReactNode;
|
|
17
17
|
/** Accessible name for a picture tile whose `label` is not a plain string. */
|
|
18
18
|
'aria-label'?: string;
|
|
19
|
+
/** `layout='list'` only: a quiet right-aligned fact about the row — a count, "you are here". */
|
|
20
|
+
trailing?: ReactNode;
|
|
21
|
+
/** `layout='list'` only: depth in levels of 1rem, so a flattened tree keeps its shape. */
|
|
22
|
+
indent?: number;
|
|
19
23
|
}
|
|
20
24
|
export interface ChoiceGroupProps<T extends string = string> {
|
|
21
25
|
options: readonly ChoiceOption<T>[];
|
|
@@ -37,11 +41,19 @@ export interface ChoiceGroupProps<T extends string = string> {
|
|
|
37
41
|
* `text` (default): a wrapping row of worded options. `picture`: one strip of
|
|
38
42
|
* square thumbnail tiles, checked by an accent ring — a cover picker, an avatar
|
|
39
43
|
* picker. The strip does not wrap; put the group in a `ScrollArea axis='x'` when
|
|
40
|
-
* it can be longer than its container.
|
|
44
|
+
* it can be longer than its container. `list`: full-width stacked rows, each with
|
|
45
|
+
* a `trailing` slot and an `indent` — a list of moments, a folder tree. Give the
|
|
46
|
+
* group a height through `className` when it can outgrow its container.
|
|
41
47
|
*/
|
|
42
|
-
layout?: 'text' | 'picture';
|
|
48
|
+
layout?: 'text' | 'picture' | 'list';
|
|
43
49
|
/** `layout='picture'` tile edge in px. Default 64. */
|
|
44
50
|
tileSize?: number;
|
|
51
|
+
/**
|
|
52
|
+
* `layout='list'` only: a double-click or Enter on a row takes it straight to the
|
|
53
|
+
* action the choice was for, without reaching for the dialog's button. `onChange`
|
|
54
|
+
* has already reported that row by then.
|
|
55
|
+
*/
|
|
56
|
+
onConfirm?: (value: T) => void;
|
|
45
57
|
className?: string;
|
|
46
58
|
}
|
|
47
59
|
/**
|
|
@@ -54,6 +66,7 @@ export interface ChoiceGroupProps<T extends string = string> {
|
|
|
54
66
|
* - a long or data-driven list → `SelectField` / `ChoiceMenu`
|
|
55
67
|
* - a color → `SwatchPicker`
|
|
56
68
|
* - one picture out of several (a cover, an avatar) → THIS, `layout='picture'`
|
|
69
|
+
* - rows that each carry two lines and a fact, or a tree → THIS, `layout='list'`
|
|
57
70
|
* - a handful of answers that each need words → THIS
|
|
58
71
|
*
|
|
59
72
|
* ── 🔴 Why this exists: the hidden-radio trap (family, 2026-09-26) ──────────
|
|
@@ -74,5 +87,5 @@ export interface ChoiceGroupProps<T extends string = string> {
|
|
|
74
87
|
* `relative` on the root contains the non-focusable form-bubble input Radix adds
|
|
75
88
|
* inside a `<form>`, so even that is positioned against this group.
|
|
76
89
|
*/
|
|
77
|
-
export declare function ChoiceGroup<T extends string = string>({ options, value, onChange, label, 'aria-label': ariaLabel, description, disabled, disabledReason, name, layout, tileSize, className, ...rest }: ChoiceGroupProps<T>): import("react").JSX.Element;
|
|
90
|
+
export declare function ChoiceGroup<T extends string = string>({ options, value, onChange, label, 'aria-label': ariaLabel, description, disabled, disabledReason, name, layout, tileSize, onConfirm, className, ...rest }: ChoiceGroupProps<T>): import("react").JSX.Element;
|
|
78
91
|
//# sourceMappingURL=ChoiceGroup.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChoiceGroup.d.ts","sourceRoot":"","sources":["../../../src/react/components/ChoiceGroup.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAS,MAAM,OAAO,CAAC;AAK9C,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACrD,2CAA2C;IAC3C,KAAK,EAAE,CAAC,CAAC;IACT,+EAA+E;IAC/E,KAAK,EAAE,SAAS,CAAC;IACjB,6CAA6C;IAC7C,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"ChoiceGroup.d.ts","sourceRoot":"","sources":["../../../src/react/components/ChoiceGroup.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAS,MAAM,OAAO,CAAC;AAK9C,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACrD,2CAA2C;IAC3C,KAAK,EAAE,CAAC,CAAC;IACT,+EAA+E;IAC/E,KAAK,EAAE,SAAS,CAAC;IACjB,6CAA6C;IAC7C,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gGAAgG;IAChG,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACzD,OAAO,EAAE,SAAS,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC;IACpC,KAAK,EAAE,CAAC,GAAG,SAAS,CAAC;IACrB,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAC7B,yEAAyE;IACzE,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB;6FACyF;IACzF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,SAAS,CAAC;IAC3B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACrC,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAQD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EACrD,OAAO,EACP,KAAK,EACL,QAAQ,EACR,KAAK,EACL,YAAY,EAAE,SAAS,EACvB,WAAW,EACX,QAAgB,EAChB,cAAc,EACd,IAAI,EACJ,MAAe,EACf,QAAa,EACb,SAAS,EACT,SAAS,EACT,GAAG,IAAI,EACR,EAAE,gBAAgB,CAAC,CAAC,CAAC,+BA4DrB"}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import * as RadioGroup from '@radix-ui/react-radio-group';
|
|
3
3
|
import { useId } from 'react';
|
|
4
|
-
import { cn } from '../lib/cn.js';
|
|
4
|
+
import { cn, RAW_BUTTON } from '../lib/cn.js';
|
|
5
5
|
import { TAP_TARGET } from '../lib/tapTarget.js';
|
|
6
6
|
import { Tooltip } from './Tooltip.js';
|
|
7
|
+
const ROOT_LAYOUT = { text: 'flex-wrap gap-1.5', picture: 'gap-2', list: 'flex-col gap-0.5' };
|
|
8
|
+
/** Focus, checked and disabled, the same on a chip and on a row. */
|
|
9
|
+
const OPTION_STATES = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-accent data-[state=checked]:bg-accent/10 data-[state=checked]:font-medium disabled:cursor-not-allowed disabled:opacity-50';
|
|
7
10
|
/**
|
|
8
11
|
* Pick ONE of a few options whose labels are sentences — "No — just their own
|
|
9
12
|
* page" / "Yes — they can edit anyone, like you". The options are all visible at
|
|
@@ -14,6 +17,7 @@ import { Tooltip } from './Tooltip.js';
|
|
|
14
17
|
* - a long or data-driven list → `SelectField` / `ChoiceMenu`
|
|
15
18
|
* - a color → `SwatchPicker`
|
|
16
19
|
* - one picture out of several (a cover, an avatar) → THIS, `layout='picture'`
|
|
20
|
+
* - rows that each carry two lines and a fact, or a tree → THIS, `layout='list'`
|
|
17
21
|
* - a handful of answers that each need words → THIS
|
|
18
22
|
*
|
|
19
23
|
* ── 🔴 Why this exists: the hidden-radio trap (family, 2026-09-26) ──────────
|
|
@@ -34,13 +38,31 @@ import { Tooltip } from './Tooltip.js';
|
|
|
34
38
|
* `relative` on the root contains the non-focusable form-bubble input Radix adds
|
|
35
39
|
* inside a `<form>`, so even that is positioned against this group.
|
|
36
40
|
*/
|
|
37
|
-
export function ChoiceGroup({ options, value, onChange, label, 'aria-label': ariaLabel, description, disabled = false, disabledReason, name, layout = 'text', tileSize = 64, className, ...rest }) {
|
|
41
|
+
export function ChoiceGroup({ options, value, onChange, label, 'aria-label': ariaLabel, description, disabled = false, disabledReason, name, layout = 'text', tileSize = 64, onConfirm, className, ...rest }) {
|
|
38
42
|
const id = useId();
|
|
39
43
|
const labelId = label != null ? `${id}-label` : undefined;
|
|
40
44
|
const descriptionId = description != null ? `${id}-desc` : undefined;
|
|
41
45
|
const reasonId = disabled && disabledReason != null ? `${id}-reason` : undefined;
|
|
42
46
|
const describedBy = [descriptionId, reasonId].filter(Boolean).join(' ') || undefined;
|
|
43
|
-
return (_jsxs("div", { ...rest, className: cn('flex min-w-0 flex-col gap-1.5', className), "data-cb-choice-group": '', children: [label != null ? (_jsx("span", { id: labelId, className: cn('text-sm font-medium text-foreground', disabled && 'opacity-60'), children: label })) : null, _jsx(RadioGroup.Root, { "aria-labelledby": labelId, "aria-label": labelId ? undefined : ariaLabel, "aria-describedby": describedBy, value: value ?? '', onValueChange: (next) => onChange(next), disabled: disabled, name: name, className: cn('relative flex min-w-0', layout
|
|
47
|
+
return (_jsxs("div", { ...rest, className: cn('flex min-w-0 flex-col gap-1.5', className), "data-cb-choice-group": '', children: [label != null ? (_jsx("span", { id: labelId, className: cn('text-sm font-medium text-foreground', disabled && 'opacity-60'), children: label })) : null, _jsx(RadioGroup.Root, { "aria-labelledby": labelId, "aria-label": labelId ? undefined : ariaLabel, "aria-describedby": describedBy, value: value ?? '', onValueChange: (next) => onChange(next), disabled: disabled, name: name, className: cn('relative flex min-w-0', ROOT_LAYOUT[layout]), children: options.map((option) => layout === 'picture' ? (_jsx(PictureTile, { option: option, size: tileSize }, option.value)) : layout === 'list' ? (_jsx(ListRow, { option: option, onConfirm: onConfirm }, option.value)) : (_jsx(RadioGroup.Item, { value: option.value, disabled: option.disabled, className: cn(RAW_BUTTON, 'flex min-w-0 max-w-full flex-col items-start rounded-md border border-border bg-transparent px-2.5 py-1.5 text-left text-sm text-foreground transition-colors', 'hover:border-accent', OPTION_STATES, 'disabled:hover:border-border', TAP_TARGET), children: _jsx(OptionWords, { option: option }) }, option.value))) }), reasonId ? (_jsx("p", { id: reasonId, className: 'text-xs text-muted-foreground', children: disabledReason })) : null, descriptionId ? (_jsx("p", { id: descriptionId, className: 'text-xs text-muted-foreground', children: description })) : null] }));
|
|
48
|
+
}
|
|
49
|
+
/** What keeps `label` and `description` two lines, in a chip and in a row alike. */
|
|
50
|
+
function OptionWords({ option }) {
|
|
51
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { className: 'min-w-0 max-w-full break-words', children: option.label }), option.description != null ? (_jsx("span", { className: 'min-w-0 max-w-full break-words text-xs font-normal text-muted-foreground', children: option.description })) : null] }));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One full-width row of a `layout='list'` group: the words on the left, `trailing` on the
|
|
55
|
+
* right. The indent is padding, not a margin, so a nested row's highlight still spans the list.
|
|
56
|
+
*/
|
|
57
|
+
function ListRow({ option, onConfirm }) {
|
|
58
|
+
return (_jsxs(RadioGroup.Item, { value: option.value, disabled: option.disabled,
|
|
59
|
+
// 0.625rem is the row's own `px-2.5`, which a `padding-inline-start` would otherwise replace.
|
|
60
|
+
style: option.indent ? { paddingInlineStart: `calc(0.625rem + ${option.indent}rem)` } : undefined, onDoubleClick: onConfirm ? () => onConfirm(option.value) : undefined, onKeyDown: onConfirm
|
|
61
|
+
? (event) => {
|
|
62
|
+
if (event.key === 'Enter')
|
|
63
|
+
onConfirm(option.value);
|
|
64
|
+
}
|
|
65
|
+
: undefined, className: cn(RAW_BUTTON, 'flex w-full min-w-0 items-center gap-3 rounded-md border border-transparent px-2.5 py-1.5 text-left text-sm text-foreground transition-colors', 'hover:bg-muted', OPTION_STATES, 'disabled:hover:bg-transparent', TAP_TARGET), children: [_jsx("span", { className: 'flex min-w-0 flex-1 flex-col items-start', children: _jsx(OptionWords, { option: option }) }), option.trailing != null ? (_jsx("span", { className: 'shrink-0 text-xs font-normal text-muted-foreground', children: option.trailing })) : null] }));
|
|
44
66
|
}
|
|
45
67
|
/**
|
|
46
68
|
* One square tile of a `layout='picture'` group. Still a Radix radio item — roving
|
|
@@ -50,7 +72,7 @@ export function ChoiceGroup({ options, value, onChange, label, 'aria-label': ari
|
|
|
50
72
|
*/
|
|
51
73
|
function PictureTile({ option, size }) {
|
|
52
74
|
const name = option['aria-label'] ?? (typeof option.label === 'string' ? option.label : undefined);
|
|
53
|
-
const item = (_jsx(RadioGroup.Item, { value: option.value, disabled: option.disabled, "aria-label": option.image != null ? name : undefined, style: { width: size, height: size }, className: cn('flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-card text-center text-caption leading-tight text-foreground transition-colors', 'hover:border-accent', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'data-[state=checked]:border-accent data-[state=checked]:ring-2 data-[state=checked]:ring-accent', option.image == null && 'px-1 data-[state=checked]:bg-accent/10', 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border'), children: option.image ?? option.label }));
|
|
75
|
+
const item = (_jsx(RadioGroup.Item, { value: option.value, disabled: option.disabled, "aria-label": option.image != null ? name : undefined, style: { width: size, height: size }, className: cn(RAW_BUTTON, 'flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-card text-center text-caption leading-tight text-foreground transition-colors', 'hover:border-accent', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'data-[state=checked]:border-accent data-[state=checked]:ring-2 data-[state=checked]:ring-accent', option.image == null && 'px-1 data-[state=checked]:bg-accent/10', 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border'), children: option.image ?? option.label }));
|
|
54
76
|
return option.image != null ? _jsx(Tooltip, { content: option.label, children: item }) : item;
|
|
55
77
|
}
|
|
56
78
|
//# sourceMappingURL=ChoiceGroup.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChoiceGroup.js","sourceRoot":"","sources":["../../../src/react/components/ChoiceGroup.tsx"],"names":[],"mappings":";AAAA,OAAO,KAAK,UAAU,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAAkB,KAAK,EAAE,MAAM,OAAO,CAAC;AAC9C,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"ChoiceGroup.js","sourceRoot":"","sources":["../../../src/react/components/ChoiceGroup.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.104.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -76,6 +76,67 @@ describe('ChoiceGroup', () => {
|
|
|
76
76
|
expect(queryByText('Only curators.')).toBeNull();
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
describe("layout='list'", () => {
|
|
80
|
+
const ROWS: ChoiceOption[] = [
|
|
81
|
+
{ value: 'root', label: 'Holidays', description: 'Holidays', trailing: 'You are here' },
|
|
82
|
+
{ value: 'nested', label: '2019', description: 'Holidays/2019', indent: 2 },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
test('each row is a radio holding its two lines and its trailing fact', () => {
|
|
86
|
+
const { getAllByRole, getByText } = render(
|
|
87
|
+
<ChoiceGroup layout='list' aria-label='Destination' options={ROWS} value='root' onChange={() => {}} />,
|
|
88
|
+
);
|
|
89
|
+
expect(getAllByRole('radio').map((radio) => radio.textContent)).toEqual(['HolidaysHolidaysYou are here', '2019Holidays/2019']);
|
|
90
|
+
// The trailing fact is a sibling of the words, so it holds the right edge however long they run.
|
|
91
|
+
expect(getByText('You are here').parentElement?.getAttribute('role')).toBe('radio');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an indent pads inside the row, on top of its own padding', () => {
|
|
95
|
+
const { getAllByRole } = render(
|
|
96
|
+
<ChoiceGroup layout='list' aria-label='Destination' options={ROWS} value='root' onChange={() => {}} />,
|
|
97
|
+
);
|
|
98
|
+
const [root, nested] = getAllByRole('radio') as [HTMLElement, HTMLElement];
|
|
99
|
+
expect(root.style.paddingInlineStart).toBe('');
|
|
100
|
+
expect(nested.style.paddingInlineStart).toBe('calc(0.625rem + 2rem)');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a double-click or Enter confirms that row, and an arrow key only chooses', () => {
|
|
104
|
+
const confirmed: string[] = [];
|
|
105
|
+
const { getAllByRole } = render(
|
|
106
|
+
<ChoiceGroup
|
|
107
|
+
layout='list'
|
|
108
|
+
aria-label='Destination'
|
|
109
|
+
options={ROWS}
|
|
110
|
+
value='root'
|
|
111
|
+
onChange={() => {}}
|
|
112
|
+
onConfirm={(next) => confirmed.push(next)}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
const [root, nested] = getAllByRole('radio') as [HTMLElement, HTMLElement];
|
|
116
|
+
fireEvent.doubleClick(nested);
|
|
117
|
+
fireEvent.keyDown(root, { key: 'Enter' });
|
|
118
|
+
fireEvent.keyDown(root, { key: 'ArrowDown' });
|
|
119
|
+
expect(confirmed).toEqual(['nested', 'root']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('with no onConfirm, a double-click is two clicks and nothing throws', () => {
|
|
123
|
+
const { getAllByRole } = render(
|
|
124
|
+
<ChoiceGroup layout='list' aria-label='Destination' options={ROWS} value='root' onChange={() => {}} />,
|
|
125
|
+
);
|
|
126
|
+
fireEvent.doubleClick(getAllByRole('radio')[1] as HTMLElement);
|
|
127
|
+
fireEvent.keyDown(getAllByRole('radio')[1] as HTMLElement, { key: 'Enter' });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// RadioGroup.Item IS a `<button>`, so `raw-button-resets-face` cannot see it: a host without
|
|
132
|
+
// Tailwind's preflight (desk) draws system-ui 13.33px on any option that does not reset itself.
|
|
133
|
+
test.each(['text', 'picture', 'list'] as const)("layout='%s' resets the browser's button face", (layout) => {
|
|
134
|
+
const { getAllByRole } = render(
|
|
135
|
+
<ChoiceGroup layout={layout} aria-label='Which' options={OPTIONS} value='member' onChange={() => {}} />,
|
|
136
|
+
);
|
|
137
|
+
for (const radio of getAllByRole('radio')) expect(radio.className).toContain('font-[inherit]');
|
|
138
|
+
});
|
|
139
|
+
|
|
79
140
|
describe("layout='picture'", () => {
|
|
80
141
|
const PICTURES: ChoiceOption[] = [
|
|
81
142
|
{ value: 'first', label: 'Whichever is first' },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as RadioGroup from '@radix-ui/react-radio-group';
|
|
2
2
|
import { type ReactNode, useId } from 'react';
|
|
3
|
-
import { cn } from '../lib/cn.js';
|
|
3
|
+
import { cn, RAW_BUTTON } from '../lib/cn.js';
|
|
4
4
|
import { TAP_TARGET } from '../lib/tapTarget.js';
|
|
5
5
|
import { Tooltip } from './Tooltip.js';
|
|
6
6
|
|
|
@@ -21,6 +21,10 @@ export interface ChoiceOption<T extends string = string> {
|
|
|
21
21
|
image?: ReactNode;
|
|
22
22
|
/** Accessible name for a picture tile whose `label` is not a plain string. */
|
|
23
23
|
'aria-label'?: string;
|
|
24
|
+
/** `layout='list'` only: a quiet right-aligned fact about the row — a count, "you are here". */
|
|
25
|
+
trailing?: ReactNode;
|
|
26
|
+
/** `layout='list'` only: depth in levels of 1rem, so a flattened tree keeps its shape. */
|
|
27
|
+
indent?: number;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface ChoiceGroupProps<T extends string = string> {
|
|
@@ -43,14 +47,28 @@ export interface ChoiceGroupProps<T extends string = string> {
|
|
|
43
47
|
* `text` (default): a wrapping row of worded options. `picture`: one strip of
|
|
44
48
|
* square thumbnail tiles, checked by an accent ring — a cover picker, an avatar
|
|
45
49
|
* picker. The strip does not wrap; put the group in a `ScrollArea axis='x'` when
|
|
46
|
-
* it can be longer than its container.
|
|
50
|
+
* it can be longer than its container. `list`: full-width stacked rows, each with
|
|
51
|
+
* a `trailing` slot and an `indent` — a list of moments, a folder tree. Give the
|
|
52
|
+
* group a height through `className` when it can outgrow its container.
|
|
47
53
|
*/
|
|
48
|
-
layout?: 'text' | 'picture';
|
|
54
|
+
layout?: 'text' | 'picture' | 'list';
|
|
49
55
|
/** `layout='picture'` tile edge in px. Default 64. */
|
|
50
56
|
tileSize?: number;
|
|
57
|
+
/**
|
|
58
|
+
* `layout='list'` only: a double-click or Enter on a row takes it straight to the
|
|
59
|
+
* action the choice was for, without reaching for the dialog's button. `onChange`
|
|
60
|
+
* has already reported that row by then.
|
|
61
|
+
*/
|
|
62
|
+
onConfirm?: (value: T) => void;
|
|
51
63
|
className?: string;
|
|
52
64
|
}
|
|
53
65
|
|
|
66
|
+
const ROOT_LAYOUT = { text: 'flex-wrap gap-1.5', picture: 'gap-2', list: 'flex-col gap-0.5' } as const;
|
|
67
|
+
|
|
68
|
+
/** Focus, checked and disabled, the same on a chip and on a row. */
|
|
69
|
+
const OPTION_STATES =
|
|
70
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-accent data-[state=checked]:bg-accent/10 data-[state=checked]:font-medium disabled:cursor-not-allowed disabled:opacity-50';
|
|
71
|
+
|
|
54
72
|
/**
|
|
55
73
|
* Pick ONE of a few options whose labels are sentences — "No — just their own
|
|
56
74
|
* page" / "Yes — they can edit anyone, like you". The options are all visible at
|
|
@@ -61,6 +79,7 @@ export interface ChoiceGroupProps<T extends string = string> {
|
|
|
61
79
|
* - a long or data-driven list → `SelectField` / `ChoiceMenu`
|
|
62
80
|
* - a color → `SwatchPicker`
|
|
63
81
|
* - one picture out of several (a cover, an avatar) → THIS, `layout='picture'`
|
|
82
|
+
* - rows that each carry two lines and a fact, or a tree → THIS, `layout='list'`
|
|
64
83
|
* - a handful of answers that each need words → THIS
|
|
65
84
|
*
|
|
66
85
|
* ── 🔴 Why this exists: the hidden-radio trap (family, 2026-09-26) ──────────
|
|
@@ -93,6 +112,7 @@ export function ChoiceGroup<T extends string = string>({
|
|
|
93
112
|
name,
|
|
94
113
|
layout = 'text',
|
|
95
114
|
tileSize = 64,
|
|
115
|
+
onConfirm,
|
|
96
116
|
className,
|
|
97
117
|
...rest
|
|
98
118
|
}: ChoiceGroupProps<T>) {
|
|
@@ -117,31 +137,28 @@ export function ChoiceGroup<T extends string = string>({
|
|
|
117
137
|
onValueChange={(next) => onChange(next as T)}
|
|
118
138
|
disabled={disabled}
|
|
119
139
|
name={name}
|
|
120
|
-
className={cn('relative flex min-w-0', layout
|
|
140
|
+
className={cn('relative flex min-w-0', ROOT_LAYOUT[layout])}
|
|
121
141
|
>
|
|
122
142
|
{options.map((option) =>
|
|
123
143
|
layout === 'picture' ? (
|
|
124
144
|
<PictureTile key={option.value} option={option} size={tileSize} />
|
|
145
|
+
) : layout === 'list' ? (
|
|
146
|
+
<ListRow key={option.value} option={option} onConfirm={onConfirm} />
|
|
125
147
|
) : (
|
|
126
148
|
<RadioGroup.Item
|
|
127
149
|
key={option.value}
|
|
128
150
|
value={option.value}
|
|
129
151
|
disabled={option.disabled}
|
|
130
152
|
className={cn(
|
|
153
|
+
RAW_BUTTON,
|
|
131
154
|
'flex min-w-0 max-w-full flex-col items-start rounded-md border border-border bg-transparent px-2.5 py-1.5 text-left text-sm text-foreground transition-colors',
|
|
132
155
|
'hover:border-accent',
|
|
133
|
-
|
|
134
|
-
'
|
|
135
|
-
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border',
|
|
156
|
+
OPTION_STATES,
|
|
157
|
+
'disabled:hover:border-border',
|
|
136
158
|
TAP_TARGET,
|
|
137
159
|
)}
|
|
138
160
|
>
|
|
139
|
-
<
|
|
140
|
-
{option.description != null ? (
|
|
141
|
-
<span className='min-w-0 break-words text-xs font-normal text-muted-foreground'>
|
|
142
|
-
{option.description}
|
|
143
|
-
</span>
|
|
144
|
-
) : null}
|
|
161
|
+
<OptionWords option={option} />
|
|
145
162
|
</RadioGroup.Item>
|
|
146
163
|
),
|
|
147
164
|
)}
|
|
@@ -160,6 +177,58 @@ export function ChoiceGroup<T extends string = string>({
|
|
|
160
177
|
);
|
|
161
178
|
}
|
|
162
179
|
|
|
180
|
+
/** What keeps `label` and `description` two lines, in a chip and in a row alike. */
|
|
181
|
+
function OptionWords<T extends string>({ option }: { option: ChoiceOption<T> }) {
|
|
182
|
+
return (
|
|
183
|
+
<>
|
|
184
|
+
<span className='min-w-0 max-w-full break-words'>{option.label}</span>
|
|
185
|
+
{option.description != null ? (
|
|
186
|
+
<span className='min-w-0 max-w-full break-words text-xs font-normal text-muted-foreground'>
|
|
187
|
+
{option.description}
|
|
188
|
+
</span>
|
|
189
|
+
) : null}
|
|
190
|
+
</>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One full-width row of a `layout='list'` group: the words on the left, `trailing` on the
|
|
196
|
+
* right. The indent is padding, not a margin, so a nested row's highlight still spans the list.
|
|
197
|
+
*/
|
|
198
|
+
function ListRow<T extends string>({ option, onConfirm }: { option: ChoiceOption<T>; onConfirm?: (value: T) => void }) {
|
|
199
|
+
return (
|
|
200
|
+
<RadioGroup.Item
|
|
201
|
+
value={option.value}
|
|
202
|
+
disabled={option.disabled}
|
|
203
|
+
// 0.625rem is the row's own `px-2.5`, which a `padding-inline-start` would otherwise replace.
|
|
204
|
+
style={option.indent ? { paddingInlineStart: `calc(0.625rem + ${option.indent}rem)` } : undefined}
|
|
205
|
+
onDoubleClick={onConfirm ? () => onConfirm(option.value) : undefined}
|
|
206
|
+
onKeyDown={
|
|
207
|
+
onConfirm
|
|
208
|
+
? (event) => {
|
|
209
|
+
if (event.key === 'Enter') onConfirm(option.value);
|
|
210
|
+
}
|
|
211
|
+
: undefined
|
|
212
|
+
}
|
|
213
|
+
className={cn(
|
|
214
|
+
RAW_BUTTON,
|
|
215
|
+
'flex w-full min-w-0 items-center gap-3 rounded-md border border-transparent px-2.5 py-1.5 text-left text-sm text-foreground transition-colors',
|
|
216
|
+
'hover:bg-muted',
|
|
217
|
+
OPTION_STATES,
|
|
218
|
+
'disabled:hover:bg-transparent',
|
|
219
|
+
TAP_TARGET,
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
<span className='flex min-w-0 flex-1 flex-col items-start'>
|
|
223
|
+
<OptionWords option={option} />
|
|
224
|
+
</span>
|
|
225
|
+
{option.trailing != null ? (
|
|
226
|
+
<span className='shrink-0 text-xs font-normal text-muted-foreground'>{option.trailing}</span>
|
|
227
|
+
) : null}
|
|
228
|
+
</RadioGroup.Item>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
163
232
|
/**
|
|
164
233
|
* One square tile of a `layout='picture'` group. Still a Radix radio item — roving
|
|
165
234
|
* tabindex, arrow keys and `aria-checked` come from the group, exactly as for a text
|
|
@@ -175,6 +244,7 @@ function PictureTile<T extends string>({ option, size }: { option: ChoiceOption<
|
|
|
175
244
|
aria-label={option.image != null ? name : undefined}
|
|
176
245
|
style={{ width: size, height: size }}
|
|
177
246
|
className={cn(
|
|
247
|
+
RAW_BUTTON,
|
|
178
248
|
'flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-card text-center text-caption leading-tight text-foreground transition-colors',
|
|
179
249
|
'hover:border-accent',
|
|
180
250
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|