cursedbelt 5.103.0 → 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.
@@ -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;CACvB;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;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;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,GAAG,IAAI,EACR,EAAE,gBAAgB,CAAC,CAAC,CAAC,+BA+DrB"}
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 === 'picture' ? 'gap-2' : 'flex-wrap gap-1.5'), children: options.map((option) => layout === 'picture' ? (_jsx(PictureTile, { option: option, size: tileSize }, option.value)) : (_jsxs(RadioGroup.Item, { value: option.value, disabled: option.disabled, className: cn('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', '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 disabled:hover:border-border', TAP_TARGET), children: [_jsx("span", { className: 'min-w-0 break-words', children: option.label }), option.description != null ? (_jsx("span", { className: 'min-w-0 break-words text-xs font-normal text-muted-foreground', children: option.description })) : null] }, 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] }));
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":"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"}
1
+ {"version":3,"file":"ChoiceGroup.js","sourceRoot":"","sources":["../../../src/react/components/ChoiceGroup.tsx"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AAsQA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB;;;sEAGkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AA2yCD,iGAAiG;AACjG,wBAAgB,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAE3D;AAoHD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAIpE;AA+6BD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,SAAS,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,GAAG,IAAI,CAEpF;AAID,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,CAoBhE;AAyKD,kGAAkG;AAClG,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAatE;AAqDD,UAAU,MAAM;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AA0BD,+FAA+F;AAC/F,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAgBpD;AAID,wEAAwE;AACxE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO5E;AAiJD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAoCtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAAiE,CAAC;AAEtH,0EAA0E;AAC1E,eAAO,MAAM,SAAS,EAAE,SAAS,MAAM,EAAwE,CAAC;AAShH,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA+C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAgHhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
1
+ {"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AAsQA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB;;;sEAGkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AAm1CD,iGAAiG;AACjG,wBAAgB,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAE3D;AAoHD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAIpE;AA+6BD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,SAAS,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,GAAG,IAAI,CAEpF;AAID,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,CAoBhE;AAyKD,kGAAkG;AAClG,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAatE;AAqDD,UAAU,MAAM;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AA0BD,+FAA+F;AAC/F,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAgBpD;AAID,wEAAwE;AACxE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO5E;AAiJD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAoCtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAAiE,CAAC;AAEtH,0EAA0E;AAC1E,eAAO,MAAM,SAAS,EAAE,SAAS,MAAM,EAAwE,CAAC;AAShH,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA+C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAgHhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.103.0",
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.",
@@ -77,9 +77,41 @@ describe('no-raw-action-button', () => {
77
77
  test('a CLOSED or self-closing group earlier in the file exempts nothing after it', () => {
78
78
  const closed = `<Tabs value={t}><TabsList /></Tabs>\n<button role="tab">Loose</button>`;
79
79
  expect(check('no-raw-action-button', closed)).toHaveLength(1);
80
- const selfClosed = `<RadioGroup options={o} />\n<button role="radio">Loose</button>`;
80
+ const selfClosed = `<SegmentedControl options={o} />\n<button role="radio">Loose</button>`;
81
81
  expect(check('no-raw-action-button', selfClosed)).toHaveLength(1);
82
82
  });
83
+
84
+ // Task 4098: the type argument's `>` ended the tag, so the real `/>` went unread and the
85
+ // control stayed open for the rest of the file. Desk's rewind list passed on exactly this.
86
+ test('🔴 a self-closing GENERIC group exempts nothing after it', () => {
87
+ const union = `<SegmentedControl<"a" | "b"> value={v} onChange={set} options={o} />
88
+ <button role="radio">Loose</button>`;
89
+ expect(check('no-raw-action-button', union)).toHaveLength(1);
90
+ const multiline = `<SegmentedControl<
91
+ Record<string, "a" | "b">
92
+ >
93
+ value={v}
94
+ onChange={(next) => set(next)}
95
+ />
96
+ <button role="tab">Loose</button>`;
97
+ expect(check('no-raw-action-button', multiline)).toHaveLength(1);
98
+ const functionType = `<Tabs<(next: string) => void> value={t} />\n<button role="switch">Loose</button>`;
99
+ expect(check('no-raw-action-button', functionType)).toHaveLength(1);
100
+ });
101
+
102
+ test('a group the belt does not ship exempts nothing, whatever an app names it', () => {
103
+ const source = `<RadioGroup value={v}>
104
+ <button role="radio">A</button>
105
+ </RadioGroup>`;
106
+ expect(check('no-raw-action-button', source)).toHaveLength(1);
107
+ });
108
+
109
+ test('a generic group left OPEN still exempts its own part', () => {
110
+ const source = `<SegmentedControl<"a" | "b"> value={v}>
111
+ <button role="radio" aria-checked={v === 'a'}>A</button>
112
+ </SegmentedControl>`;
113
+ expect(check('no-raw-action-button', source)).toHaveLength(0);
114
+ });
83
115
  });
84
116
 
85
117
  // Filed 2026-09-15 after three false positives in three repos, all one defect: the tag
@@ -122,11 +154,11 @@ describe('opening-tag extraction (comment-aware)', () => {
122
154
  });
123
155
 
124
156
  test('a prose angle bracket does not hide a role="radio" below it', () => {
125
- const source = `<RadioGroup value={v}><button
157
+ const source = `<SegmentedControl value={v}><button
126
158
  /* A radio-like list rather than a <select>. */
127
159
  role="radio"
128
160
  aria-checked={selected}
129
- >Move here</button></RadioGroup>`;
161
+ >Move here</button></SegmentedControl>`;
130
162
  expect(check('no-raw-action-button', source)).toHaveLength(0);
131
163
  });
132
164
 
@@ -206,12 +238,12 @@ describe('a presentational marker must be an ATTRIBUTE, not prose about one', ()
206
238
  });
207
239
 
208
240
  test('the REAL attributes still pass silently, prose and all', () => {
209
- const source = `<RadioGroup value={v}><button
241
+ const source = `<SegmentedControl value={v}><button
210
242
  // A radio-like list: \`role="radio"\` + \`aria-checked\` says so to a screen reader.
211
243
  role="radio"
212
244
  aria-checked={selected}
213
245
  type="button"
214
- >Move here</button></RadioGroup>`;
246
+ >Move here</button></SegmentedControl>`;
215
247
  expect(check('no-raw-action-button', source)).toHaveLength(0);
216
248
  });
217
249
 
@@ -210,8 +210,8 @@
210
210
  //
211
211
  // A presentational ARIA role/attribute on the tag (role="tab" | "switch" | "radio" |
212
212
  // "menuitemradio" | "presentation", or aria-selected) exempts a `<button>` from
213
- // `no-raw-action-button` ONLY inside a belt `Tabs`/`TabsList`/`SegmentedControl`/
214
- // `RadioGroup`/`ToggleGroup` (task 2381 — a bare marker was the loophole; see the rule).
213
+ // `no-raw-action-button` ONLY inside a belt `Tabs`/`TabsList`/`SegmentedControl`
214
+ // (task 2381 — a bare marker was the loophole; see the rule).
215
215
  //
216
216
  // 🔴 Every opt-out is COUNTED (`opt-out-count`) and must lead with its cause
217
217
  // (`opt-out-needs-cause`): `guardrails-ignore <rule>: <cause> — <six+ words of why>`, the
@@ -356,6 +356,38 @@ interface OpeningTag {
356
356
  readonly code: string;
357
357
  }
358
358
 
359
+ function endOfTagName(source: string, startIndex: number): number {
360
+ const name = /<[\w$.:-]+/y;
361
+ name.lastIndex = startIndex;
362
+ return name.test(source) ? name.lastIndex : startIndex;
363
+ }
364
+
365
+ /** The index past a JSX type argument at `index` (`<SegmentedControl<"a" | "b"> …`), or
366
+ * `index` when none opens there or it never closes. Its `>` is not the end of the tag: read
367
+ * as one, a self-closing generic control looked OPEN, and with no `</SegmentedControl>` to
368
+ * shut it every role-marked button below it passed `no-raw-action-button` (forge task 4098).
369
+ * The `>` of a `=>` inside a function type closes nothing. */
370
+ function endOfTypeArguments(source: string, index: number): number {
371
+ if (source[index] !== "<") return index;
372
+ let depth = 0;
373
+ let i = index;
374
+ while (i < source.length) {
375
+ const ch = source[i];
376
+ if (ch === '"' || ch === "'" || ch === "`") {
377
+ i = skipQuoted(source, i);
378
+ continue;
379
+ }
380
+ if (source.slice(i, i + 2) === "=>") {
381
+ i += 2;
382
+ continue;
383
+ }
384
+ if (ch === "<") depth++;
385
+ else if (ch === ">" && --depth === 0) return i + 1;
386
+ i++;
387
+ }
388
+ return index;
389
+ }
390
+
359
391
  /** Read from a `<button` (or similar) match to the end of its opening tag, tolerating
360
392
  * `{...}` JS expressions in attribute values that may themselves contain `>`
361
393
  * (`onClick={() => …}`, which works because that `>` sits inside braces).
@@ -372,6 +404,12 @@ function readOpeningTag(source: string, startIndex: number): OpeningTag {
372
404
  let i = startIndex;
373
405
  let braceDepth = 0;
374
406
  let code = "";
407
+ const nameEnd = endOfTagName(source, startIndex);
408
+ const typeEnd = endOfTypeArguments(source, nameEnd);
409
+ if (typeEnd > nameEnd) {
410
+ code = source.slice(startIndex, nameEnd) + blank(source.slice(nameEnd, typeEnd));
411
+ i = typeEnd;
412
+ }
375
413
  while (i < source.length) {
376
414
  const two = source.slice(i, i + 2);
377
415
  if (two === "//") {
@@ -754,14 +792,16 @@ const PRESENTATIONAL_MARKER_RE =
754
792
  // Task 2381: a role marker used to exempt ANY raw button, so the cheapest way past this rule
755
793
  // was `role="switch"` — `apps/family/src/App.tsx:1786-1800` (`EditModeToggle`) says in its
756
794
  // own comment the marker is there to satisfy it, beside the belt's `Switch`. A marker now
757
- // exempts only a button INSIDE a belt group that owns that role (`Tabs`, `SegmentedControl`,
758
- // `RadioGroup`, `ToggleGroup`) — the one place a raw button with it is the group's own part.
759
- // Anywhere else it is `Switch` / `Tabs` / `SegmentedControl` / `RadioGroup`, or an opt-out
795
+ // exempts only a button INSIDE a belt group that owns that role (`Tabs`, `SegmentedControl`)
796
+ // — the one place a raw button with it is the group's own part. `ChoiceGroup` takes no
797
+ // children, so nothing can sit inside it, and a `RadioGroup` or `ToggleGroup` is no belt
798
+ // component: naming them only exempted whatever an app happened to call that (forge task 4098).
799
+ // Anywhere else it is `Switch` / `Tabs` / `SegmentedControl` / `ChoiceGroup`, or an opt-out
760
800
  // with its cause.
761
801
  const BELT_OWN_SRC = `${join(import.meta.dir, "..", "src")}/`;
762
802
  const isBeltOwnSource = (filePath: string) =>
763
803
  resolve(filePath).startsWith(BELT_OWN_SRC) || /(^|\/)(?:packages|libs)\/cursedbelt\/src\//.test(filePath.replace(/\\/g, "/"));
764
- const BELT_CHOICE_GROUPS = ["Tabs", "TabsList", "SegmentedControl", "RadioGroup", "ToggleGroup"];
804
+ const BELT_CHOICE_GROUPS = ["Tabs", "TabsList", "SegmentedControl"];
765
805
 
766
806
  /** Is `index` inside an open (not yet closed) belt choice-group element in this file? */
767
807
  function insideBeltChoiceGroup(scannable: string, index: number): boolean {
@@ -778,7 +818,7 @@ function insideBeltChoiceGroup(scannable: string, index: number): boolean {
778
818
  const noRawActionButton: Rule = {
779
819
  name: "no-raw-action-button",
780
820
  description:
781
- 'DESIGN.md §2 (Component Architecture): no hand-rolled <button> — use the cursedbelt Button family; a toggle is `Switch`, a tab `Tabs`, a choice `SegmentedControl`/`RadioGroup`. A role marker exempts a button only inside one of those groups.',
821
+ 'DESIGN.md §2 (Component Architecture): no hand-rolled <button> — use the cursedbelt Button family; a toggle is `Switch`, a tab `Tabs`, a choice `SegmentedControl`/`ChoiceGroup`. A role marker exempts a button only inside `Tabs` or `SegmentedControl`.',
782
822
  check(filePath, source) {
783
823
  const violations: Violation[] = [];
784
824
  const scannable = blankNonMarkupText(source, filePath);
@@ -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 === 'picture' ? 'gap-2' : 'flex-wrap gap-1.5')}
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
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
134
- 'data-[state=checked]:border-accent data-[state=checked]:bg-accent/10 data-[state=checked]:font-medium',
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
- <span className='min-w-0 break-words'>{option.label}</span>
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',