cursedbelt 5.59.0 → 5.63.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.
Files changed (75) hide show
  1. package/dist/react/components/ColorSwatches.d.ts +33 -0
  2. package/dist/react/components/ColorSwatches.d.ts.map +1 -0
  3. package/dist/react/components/ColorSwatches.js +42 -0
  4. package/dist/react/components/ColorSwatches.js.map +1 -0
  5. package/dist/react/components/EditorPage.d.ts +1 -0
  6. package/dist/react/components/EditorPage.d.ts.map +1 -1
  7. package/dist/react/components/EditorPage.js +6 -1
  8. package/dist/react/components/EditorPage.js.map +1 -1
  9. package/dist/react/components/FormPreviewLayout.d.ts +1 -0
  10. package/dist/react/components/FormPreviewLayout.d.ts.map +1 -1
  11. package/dist/react/components/FormPreviewLayout.js +2 -1
  12. package/dist/react/components/FormPreviewLayout.js.map +1 -1
  13. package/dist/react/components/PropertyList.d.ts +17 -3
  14. package/dist/react/components/PropertyList.d.ts.map +1 -1
  15. package/dist/react/components/PropertyList.js +11 -3
  16. package/dist/react/components/PropertyList.js.map +1 -1
  17. package/dist/react/components/SwatchPicker.d.ts +37 -9
  18. package/dist/react/components/SwatchPicker.d.ts.map +1 -1
  19. package/dist/react/components/SwatchPicker.js +52 -9
  20. package/dist/react/components/SwatchPicker.js.map +1 -1
  21. package/dist/react/components/TreeView.d.ts +39 -4
  22. package/dist/react/components/TreeView.d.ts.map +1 -1
  23. package/dist/react/components/TreeView.js +54 -15
  24. package/dist/react/components/TreeView.js.map +1 -1
  25. package/dist/react/components/editorPage.css +1 -7
  26. package/dist/react/components/formKit.css +5 -6
  27. package/dist/react/components/scrollRegion.css +31 -0
  28. package/dist/react/index.d.ts +2 -1
  29. package/dist/react/index.d.ts.map +1 -1
  30. package/dist/react/index.js +1 -0
  31. package/dist/react/index.js.map +1 -1
  32. package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
  33. package/dist/react/master-detail/MasterDetail.js +1 -1
  34. package/dist/react/master-detail/MasterDetail.js.map +1 -1
  35. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  36. package/dist/styles-areas/core.css +6 -6
  37. package/dist/styles-areas/disk-usage.css +7 -7
  38. package/dist/styles-areas/fields.css +24 -22
  39. package/dist/styles-areas/master-detail.css +2 -2
  40. package/dist/styles-areas/nav.css +25 -26
  41. package/dist/styles-areas/workbench.css +2 -2
  42. package/dist/styles-utilities.css +31 -0
  43. package/dist/theme.css +23 -20
  44. package/package.json +24 -5
  45. package/scripts/guardrails-baseline.json +2 -2
  46. package/scripts/guardrailsEnforce.spec.ts +31 -0
  47. package/scripts/guardrailsEnforce.ts +37 -2
  48. package/scripts/styleAreas.ts +1 -0
  49. package/src/react/components/ColorSwatches.spec.tsx +62 -0
  50. package/src/react/components/ColorSwatches.tsx +100 -0
  51. package/src/react/components/EditorPage.spec.tsx +18 -0
  52. package/src/react/components/EditorPage.tsx +12 -2
  53. package/src/react/components/FormPreviewLayout.tsx +2 -1
  54. package/src/react/components/PropertyList.spec.tsx +33 -0
  55. package/src/react/components/PropertyList.tsx +31 -6
  56. package/src/react/components/SwatchPicker.spec.tsx +92 -0
  57. package/src/react/components/SwatchPicker.tsx +119 -33
  58. package/src/react/components/TreeView.spec.tsx +157 -0
  59. package/src/react/components/TreeView.tsx +161 -14
  60. package/src/react/components/editorPage.css +1 -7
  61. package/src/react/components/formKit.css +5 -6
  62. package/src/react/components/formKit.spec.tsx +9 -0
  63. package/src/react/components/scrollRegion.css +31 -0
  64. package/src/react/index.ts +2 -0
  65. package/src/react/master-detail/MasterDetail.spec.tsx +21 -0
  66. package/src/react/master-detail/MasterDetail.tsx +7 -3
  67. package/src/styles-areas/core.css +6 -6
  68. package/src/styles-areas/disk-usage.css +7 -7
  69. package/src/styles-areas/fields.css +24 -22
  70. package/src/styles-areas/master-detail.css +2 -2
  71. package/src/styles-areas/nav.css +25 -26
  72. package/src/styles-areas/workbench.css +2 -2
  73. package/src/styles-utilities.css +31 -0
  74. package/src/theme.css +23 -20
  75. package/src/themeDarkScopeAliases.spec.ts +30 -0
@@ -0,0 +1,62 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { ColorSwatches } from './ColorSwatches.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ // guardrails-ignore no-hardcoded-colors: task 2829 — a colour picker's test data; the browser's well speaks only hex, so hex is the subject
8
+ const [BLUE, GREEN, RED, CUSTOM, PICKED] = ['#2563eb', '#16a34a', '#DC2626', '#123456', '#ABCDEF'];
9
+ const PRESETS = [BLUE, GREEN, RED];
10
+
11
+ describe('ColorSwatches', () => {
12
+ test('the presets are ONE radiogroup, named, one radio per dot, then the any-colour well', () => {
13
+ const { getByRole, getAllByRole, getByLabelText } = render(
14
+ <ColorSwatches aria-label='Accent' presets={PRESETS} value={BLUE} onChange={() => {}} />,
15
+ );
16
+ expect(getByRole('radiogroup').getAttribute('aria-label')).toBe('Accent');
17
+ expect(getAllByRole('radio')).toHaveLength(3);
18
+ const well = getByLabelText('Any colour') as HTMLInputElement;
19
+ expect(well.type).toBe('color');
20
+ });
21
+
22
+ test('a preset value checks its dot — case-insensitively — and leaves the well unchecked', () => {
23
+ const { getAllByRole, container } = render(<ColorSwatches presets={PRESETS} value={RED.toLowerCase()} onChange={() => {}} />);
24
+ const checked = getAllByRole('radio').filter((radio) => radio.getAttribute('aria-checked') === 'true');
25
+ expect(checked.map((radio) => radio.getAttribute('aria-label'))).toEqual([RED]);
26
+ expect(container.querySelector('[data-slot="color-swatches-custom"]')?.getAttribute('data-state')).toBe('unchecked');
27
+ });
28
+
29
+ test('🔴 a colour outside the presets checks the WELL, which wears it — no preset lights (task 2829)', () => {
30
+ const { getAllByRole, container, getByLabelText } = render(<ColorSwatches presets={PRESETS} value={CUSTOM} onChange={() => {}} />);
31
+ expect(getAllByRole('radio').some((radio) => radio.getAttribute('aria-checked') === 'true')).toBe(false);
32
+ const custom = container.querySelector<HTMLElement>('[data-slot="color-swatches-custom"]');
33
+ expect(custom?.getAttribute('data-state')).toBe('checked');
34
+ expect(custom?.getAttribute('style')).toContain(CUSTOM);
35
+ expect((getByLabelText('Any colour') as HTMLInputElement).value).toBe(CUSTOM);
36
+ });
37
+
38
+ test('with no custom colour the well is painted from the theme ramp, not literals', () => {
39
+ const { container } = render(<ColorSwatches presets={PRESETS} value={BLUE} onChange={() => {}} />);
40
+ const custom = container.querySelector<HTMLElement>('[data-slot="color-swatches-custom"]');
41
+ expect(custom?.getAttribute('style')).toContain('var(--chart-1)');
42
+ });
43
+
44
+ test('picking a dot reports its preset; picking in the well reports lower-case hex', () => {
45
+ const picked: string[] = [];
46
+ const { getAllByRole, getByLabelText } = render(<ColorSwatches presets={PRESETS} value={BLUE} onChange={(c) => picked.push(c)} />);
47
+ fireEvent.click(getAllByRole('radio')[1] as HTMLElement);
48
+ fireEvent.change(getByLabelText('Any colour'), { target: { value: PICKED } });
49
+ expect(picked).toEqual([GREEN, PICKED.toLowerCase()]);
50
+ });
51
+
52
+ test('the well follows a value changed from outside', () => {
53
+ const { getByLabelText, rerender } = render(<ColorSwatches presets={PRESETS} value={CUSTOM} onChange={() => {}} />);
54
+ rerender(<ColorSwatches presets={PRESETS} value={GREEN} onChange={() => {}} />);
55
+ expect((getByLabelText('Any colour') as HTMLInputElement).value).toBe(GREEN);
56
+ });
57
+
58
+ test('`custom={false}` is the presets alone', () => {
59
+ const { queryByLabelText } = render(<ColorSwatches presets={PRESETS} value={undefined} custom={false} onChange={() => {}} />);
60
+ expect(queryByLabelText('Any colour')).toBeNull();
61
+ });
62
+ });
@@ -0,0 +1,100 @@
1
+ import { type CSSProperties, useEffect, useRef } from 'react';
2
+ import { cn } from '../lib/cn.js';
3
+ import { TAP_TARGET_SQUARE } from '../lib/tapTarget.js';
4
+ import { SwatchPicker } from './SwatchPicker.js';
5
+
6
+ export interface ColorSwatchesProps {
7
+ /** The preset dots, in order. Any CSS colour; a 6-digit hex is what the custom well can open on. */
8
+ presets: readonly string[];
9
+ /** The current colour — one of the presets, or any other colour picked through the well. */
10
+ value: string | undefined;
11
+ onChange: (color: string) => void;
12
+ /** Offer a last dot for any colour (the browser's own colour well). Default `true`. */
13
+ custom?: boolean;
14
+ /** The custom dot's accessible name. Default "Any colour". */
15
+ customLabel?: string;
16
+ /** Dot diameter, as `SwatchPicker`'s: `sm` 1.5rem, `md` (default) 1.75rem, `lg` 2.25rem. */
17
+ size?: 'sm' | 'md' | 'lg';
18
+ /** Accessible name for the preset group (default "Colour"). */
19
+ 'aria-label'?: string;
20
+ className?: string;
21
+ }
22
+
23
+ const SIZE = { sm: 'size-6', md: 'size-7', lg: 'size-9' } as const;
24
+ const HEX6 = /^#[0-9a-f]{6}$/i;
25
+ /** The any-colour dot before a custom colour is picked: the theme's categorical ramp, round the dial. */
26
+ const RAMP =
27
+ 'conic-gradient(var(--chart-1), var(--chart-2), var(--chart-3), var(--chart-4), var(--chart-5), var(--chart-6), var(--chart-7), var(--chart-8), var(--chart-1))';
28
+
29
+ /**
30
+ * `ColorSwatches` — an accent picker drawn as ONE row of dots: the presets, then a last dot for
31
+ * any colour. The preset dots are a {@link SwatchPicker} radiogroup (Radix: radio semantics,
32
+ * roving tabindex, arrow keys move the choice); the last dot is the browser's colour well, laid
33
+ * invisibly over a dot painted with the theme's categorical ramp, and becomes that colour —
34
+ * ringed as the current one — once a colour outside the presets is picked.
35
+ *
36
+ * desk drew this twice by hand (its component and list template pages, forge task 2829): a row
37
+ * of raw buttons, one of them with a raw `<input type="color">` behind an opt-out and the other
38
+ * with no custom colour at all. `ColorField` is a full-width hex field; this is the compact
39
+ * "pick an accent for this thing" control, and the next app that wants one takes it from here.
40
+ *
41
+ * Matching is case-insensitive, and what the well reports is lower-case hex, so a stored
42
+ * `#2563EB` still lights its preset.
43
+ */
44
+ export function ColorSwatches({
45
+ presets,
46
+ value,
47
+ onChange,
48
+ custom = true,
49
+ customLabel = 'Any colour',
50
+ size = 'md',
51
+ 'aria-label': ariaLabel = 'Colour',
52
+ className,
53
+ }: ColorSwatchesProps) {
54
+ const current = value?.toLowerCase();
55
+ const isPreset = current !== undefined && presets.some((color) => color.toLowerCase() === current);
56
+ const isCustom = current !== undefined && current !== '' && !isPreset;
57
+ // The well opens on the current colour when it is one it can show. It is uncontrolled and kept
58
+ // in step here, because a controlled colour input needs a hex for "nothing chosen" and there is
59
+ // no honest one to write.
60
+ const wellHex = current !== undefined && HEX6.test(current) ? current : undefined;
61
+ const well = useRef<HTMLInputElement>(null);
62
+ useEffect(() => {
63
+ if (well.current && wellHex && well.current.value !== wellHex) well.current.value = wellHex;
64
+ }, [wellHex]);
65
+ return (
66
+ <div data-slot='color-swatches' className={cn('flex flex-wrap items-center gap-1.5', className)}>
67
+ <SwatchPicker
68
+ aria-label={ariaLabel}
69
+ size={size}
70
+ options={presets.map((color) => ({ value: color.toLowerCase(), color, label: color }))}
71
+ value={isPreset ? current : undefined}
72
+ onChange={onChange}
73
+ />
74
+ {custom ? (
75
+ <label
76
+ data-slot='color-swatches-custom'
77
+ data-state={isCustom ? 'checked' : 'unchecked'}
78
+ style={{ '--cb-swatch-fill': isCustom ? value : RAMP } as CSSProperties}
79
+ className={cn(
80
+ 'relative inline-flex flex-none cursor-pointer rounded-full [background:var(--cb-swatch-fill,var(--muted))] border border-border/40 transition-transform',
81
+ SIZE[size],
82
+ TAP_TARGET_SQUARE,
83
+ 'hover:scale-110 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1',
84
+ 'data-[state=checked]:ring-2 data-[state=checked]:ring-foreground data-[state=checked]:ring-offset-1',
85
+ )}
86
+ >
87
+ <input
88
+ // guardrails-ignore no-raw-inputs: task 2829 — the browser's own colour well, laid invisibly over the last dot; a hex field is ColorField's job, not a dot's
89
+ type='color'
90
+ ref={well}
91
+ aria-label={customLabel}
92
+ defaultValue={wellHex}
93
+ onChange={(event) => onChange(event.target.value.toLowerCase())}
94
+ className='absolute inset-0 m-0 size-full cursor-pointer appearance-none border-0 p-0 opacity-0'
95
+ />
96
+ </label>
97
+ ) : null}
98
+ </div>
99
+ );
100
+ }
@@ -38,6 +38,24 @@ describe('EditorPage', () => {
38
38
  expect(view.getByLabelText('Show names')).toBeTruthy();
39
39
  });
40
40
 
41
+ test('each side scrolls on its own through the form kit’s scroller (forge task 2827)', () => {
42
+ const view = render(<Page />);
43
+ expect(view.getByLabelText('Name').closest('.cbfk-page-form')?.classList.contains('cbfk-scroll')).toBe(true);
44
+ expect(view.getByRole('region', { name: 'Card' }).classList.contains('cbfk-scroll')).toBe(true);
45
+ });
46
+
47
+ test('a focused field is scrolled into view, as ScrollArea does (forge task 2827)', async () => {
48
+ const view = render(<Page />);
49
+ const field = view.getByLabelText('Name');
50
+ let scrolled = 0;
51
+ field.scrollIntoView = () => {
52
+ scrolled++;
53
+ };
54
+ fireEvent.focus(field);
55
+ await Promise.resolve();
56
+ expect(scrolled).toBe(1);
57
+ });
58
+
41
59
  test('the form is quiet by default — Linear’s look, inside .cbfk-quiet', () => {
42
60
  const view = render(<Page />);
43
61
  expect(view.getByLabelText('Name').closest('.cbfk-quiet')).not.toBeNull();
@@ -10,11 +10,13 @@ import {
10
10
  useRef,
11
11
  useState,
12
12
  } from 'react';
13
+ import { useCaretInView } from '../hooks/useCaretInView.js';
13
14
  import { useDiscardGuard } from '../hooks/useDiscardGuard.js';
14
15
  import { cn } from '../lib/cn.js';
15
16
  import { UnstyledButton } from './UnstyledButton.js';
16
17
  import './formKit.css';
17
18
  import './editorPage.css';
19
+ import './scrollRegion.css';
18
20
 
19
21
  export interface EditorPageProps {
20
22
  /** What is being made or changed — *"New template"*, *"Edit Dynatrace"*. The page's heading. */
@@ -113,6 +115,9 @@ export function EditorPage({
113
115
  const titleId = useId();
114
116
  const formId = useId();
115
117
  const previewId = useId();
118
+ // `.cbfk-scroll`'s other half: ScrollArea's explicit scroll-into-view on focus, for a browser
119
+ // that ignores scroll-padding on its own (forge task 2827).
120
+ const onFocusCapture = useCaretInView();
116
121
 
117
122
  const { guardedClose, discardOverlay, isConfirming, cancelDiscard } = useDiscardGuard({ onClose: onBack, isDirty });
118
123
 
@@ -227,7 +232,7 @@ export function EditorPage({
227
232
  className='cbfk-page-split'
228
233
  style={{ '--cbfk-page-split': `${(split * 100).toFixed(2)}%` } as CSSProperties}
229
234
  >
230
- <div id={formId} className={cn('cbfk-page-form', quiet && 'cbfk-quiet')}>
235
+ <div id={formId} className={cn('cbfk-page-form cbfk-scroll', quiet && 'cbfk-quiet')} onFocusCapture={onFocusCapture}>
231
236
  <div className='cbfk-page-form-inner'>{children}</div>
232
237
  </div>
233
238
  {/* biome-ignore lint/a11y/useSemanticElements: WAI-ARIA's window splitter is a focusable separator with a value; an <hr> is a static rule that takes neither. */}
@@ -248,7 +253,12 @@ export function EditorPage({
248
253
  onKeyDown={onDividerKey}
249
254
  onDoubleClick={() => commit(defaultSplit)}
250
255
  />
251
- <section id={previewId} className='cbfk-page-preview' aria-label={previewLabel}>
256
+ <section
257
+ id={previewId}
258
+ className='cbfk-page-preview cbfk-scroll'
259
+ aria-label={previewLabel}
260
+ onFocusCapture={onFocusCapture}
261
+ >
252
262
  <div className='cbfk-page-preview-head'>
253
263
  <p className='cbfk-page-preview-label'>{previewLabel}</p>
254
264
  {previewTools ? <div className='cbfk-page-preview-tools'>{previewTools}</div> : null}
@@ -2,6 +2,7 @@ import { ChevronDown } from 'lucide-react';
2
2
  import { type ReactNode, useId, useState } from 'react';
3
3
  import { cn } from '../lib/cn.js';
4
4
  import './formKit.css';
5
+ import './scrollRegion.css';
5
6
 
6
7
  export interface FormPreviewLayoutProps {
7
8
  /** The configuration column — names, pickers, the short fields. */
@@ -80,7 +81,7 @@ export function FormPreviewLayout({
80
81
  <ChevronDown aria-hidden />
81
82
  </button>
82
83
  <p className='cbfk-split-head'>{previewLabel}</p>
83
- <div id={bodyId} className='cbfk-split-body'>
84
+ <div id={bodyId} className='cbfk-split-body cbfk-scroll'>
84
85
  {preview}
85
86
  </div>
86
87
  </section>
@@ -107,4 +107,37 @@ describe('PropertyList', () => {
107
107
  expect(dl?.getAttribute('data-density')).toBe('compact');
108
108
  expect(container.querySelectorAll('dt')).toHaveLength(1);
109
109
  });
110
+ test('a row carries what the app hangs off it — a class, a data attribute — on the row itself (task 2823)', () => {
111
+ const { container } = render(
112
+ <PropertyList>
113
+ <Property label='Command' className='app-row' data-type='code' data-overridden='true'>
114
+ kubectl logs
115
+ </Property>
116
+ </PropertyList>,
117
+ );
118
+ const row = container.querySelector('dl > div');
119
+ expect(row?.className).toContain('contents');
120
+ expect(row?.className).toContain('app-row');
121
+ expect(row?.getAttribute('data-type')).toBe('code');
122
+ expect(row?.getAttribute('data-overridden')).toBe('true');
123
+ expect(row?.querySelector('dt')?.textContent).toBe('Command');
124
+ });
125
+
126
+ test('a stacked fact spans both tracks — its label above, its value the whole width (task 2823)', () => {
127
+ const { container } = render(
128
+ <PropertyList>
129
+ <Property label='Command' stacked>
130
+ <pre>kubectl logs -f deploy/api</pre>
131
+ </Property>
132
+ <Property label='Owner'>Ada</Property>
133
+ </PropertyList>,
134
+ );
135
+ const [stacked, inline] = [...container.querySelectorAll('dl > div')];
136
+ expect(stacked?.getAttribute('data-stacked')).toBe('true');
137
+ expect(stacked?.querySelector('dt')?.className).toContain('col-span-full');
138
+ expect(stacked?.querySelector('dd')?.className).toContain('col-span-full');
139
+ expect(stacked?.querySelector('dd')?.className).not.toMatch(/(^|\s)items-center(\s|$)/);
140
+ expect(inline?.hasAttribute('data-stacked')).toBe(false);
141
+ expect(inline?.querySelector('dt')?.className).not.toContain('col-span-full');
142
+ });
110
143
  });
@@ -45,7 +45,7 @@ export function PropertyList({ density = "comfortable", className, children, ...
45
45
  );
46
46
  }
47
47
 
48
- export interface PropertyProps {
48
+ export interface PropertyProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
49
49
  /** What the fact is. Plain words, sentence case (CPY-9). */
50
50
  label: ReactNode;
51
51
  /** A 16px mark before the value — Linear's status ring, a person, a calendar. */
@@ -56,18 +56,42 @@ export interface PropertyProps {
56
56
  mono?: boolean;
57
57
  /** Trailing controls that belong to this fact — a copy button, a reset (FRM-6). */
58
58
  action?: ReactNode;
59
+ /**
60
+ * The value takes the list's whole width, under its label — a code block, an image, a
61
+ * paragraph that a value column would squeeze to a word per line. The label stays in the
62
+ * same muted voice, so a stacked fact still reads as one of the list's.
63
+ */
64
+ stacked?: boolean;
59
65
  children: ReactNode;
60
66
  }
61
67
 
62
- /** One fact. Renders nothing when its value is absent, so a caller never filters by hand. */
63
- export function Property({ label, icon, empty = false, mono = false, action, children }: PropertyProps) {
68
+ /**
69
+ * One fact. Renders nothing when its value is absent, so a caller never filters by hand.
70
+ *
71
+ * The row is a `display: contents` wrapper, so the label and value sit on the LIST's shared
72
+ * tracks. Anything else given to it — a `className`, a `data-type`, a `data-tone` — lands on that
73
+ * wrapper, which is what lets an app hang its own customising (a highlighted override, a row a
74
+ * test finds by type, tools shown on `:hover`) off the row without hand-rolling one (desk, task
75
+ * 2823: ten `<dl>`s whose rows each carried exactly that).
76
+ */
77
+ export function Property({ label, icon, empty = false, mono = false, action, stacked = false, className, children, ...row }: PropertyProps) {
64
78
  if (children === undefined || children === null || children === "" || children === false) return null;
65
79
  return (
66
- <div className="contents">
67
- <dt className="m-0 flex min-h-[var(--control-h-sm)] min-w-0 items-center font-normal text-[var(--muted-foreground)] group-data-[density=compact]/properties:min-h-6 group-data-[density=compact]/properties:items-start">
80
+ <div {...row} data-stacked={stacked ? "true" : undefined} className={cn("contents", className)}>
81
+ <dt
82
+ className={cn(
83
+ "m-0 flex min-h-[var(--control-h-sm)] min-w-0 items-center font-normal text-[var(--muted-foreground)] group-data-[density=compact]/properties:min-h-6 group-data-[density=compact]/properties:items-start",
84
+ stacked && "col-span-full",
85
+ )}
86
+ >
68
87
  <span className="truncate">{label}</span>
69
88
  </dt>
70
- <dd className="m-0 flex min-h-[var(--control-h-sm)] min-w-0 items-center gap-2 group-data-[density=compact]/properties:min-h-6 group-data-[density=compact]/properties:items-start">
89
+ <dd
90
+ className={cn(
91
+ "m-0 flex min-h-[var(--control-h-sm)] min-w-0 items-center gap-2 group-data-[density=compact]/properties:min-h-6 group-data-[density=compact]/properties:items-start",
92
+ stacked && "col-span-full items-start pb-2",
93
+ )}
94
+ >
71
95
  {icon ? (
72
96
  <span aria-hidden="true" className="flex size-4 flex-none items-center justify-center text-[var(--muted-foreground)]">
73
97
  {icon}
@@ -79,6 +103,7 @@ export function Property({ label, icon, empty = false, mono = false, action, chi
79
103
  data-slot="property-value"
80
104
  className={cn(
81
105
  "min-w-0",
106
+ stacked && "w-full",
82
107
  mono ? "break-all font-mono" : "break-words",
83
108
  empty ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]",
84
109
  )}
@@ -0,0 +1,92 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { type SwatchOption, SwatchPicker } from './SwatchPicker.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ const OPTIONS: SwatchOption[] = [
8
+ { value: '', none: true, label: 'No colour' },
9
+ { value: '1', color: 'var(--chart-1)', label: 'Red' },
10
+ { value: '2', color: 'var(--chart-2)', label: 'Green' },
11
+ { value: '3', color: 'var(--chart-3)', label: 'Blue' },
12
+ ];
13
+
14
+ /** Radix's roving focus moves on a timeout, so an arrow key lands a tick later. */
15
+ const tick = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
16
+
17
+ const radios = (getAllByRole: (role: string) => HTMLElement[]) => getAllByRole('radio');
18
+
19
+ describe('SwatchPicker', () => {
20
+ test('one named radiogroup, one radio per swatch, named by its label — and no native `title` (DESIGN.md §4)', () => {
21
+ const { getByRole, getAllByRole, container } = render(
22
+ <SwatchPicker aria-label='Card colour' options={OPTIONS} value='2' onChange={() => {}} />,
23
+ );
24
+ expect(getByRole('radiogroup').getAttribute('aria-label')).toBe('Card colour');
25
+ expect(radios(getAllByRole).map((r) => r.getAttribute('aria-label'))).toEqual(['No colour', 'Red', 'Green', 'Blue']);
26
+ expect(radios(getAllByRole).map((r) => r.getAttribute('aria-checked'))).toEqual(['false', 'false', 'true', 'false']);
27
+ expect(container.querySelector('[title]')).toBeNull();
28
+ });
29
+
30
+ test('a `none` option is the ground struck through, marked `data-swatch="none"`, with no check glyph', () => {
31
+ const { getAllByRole } = render(<SwatchPicker options={OPTIONS} value='' onChange={() => {}} />);
32
+ const none = radios(getAllByRole)[0] as HTMLElement;
33
+ expect(none.getAttribute('data-swatch')).toBe('none');
34
+ expect(none.getAttribute('aria-checked')).toBe('true');
35
+ expect(none.getAttribute('style')).toContain('var(--card)');
36
+ expect(none.querySelector('svg')).toBeNull();
37
+ });
38
+
39
+ test('`columns` lays the swatches out as a grid that many across; `tile` squares them', () => {
40
+ const { getByRole, getAllByRole } = render(
41
+ <SwatchPicker options={OPTIONS} value={undefined} onChange={() => {}} columns={2} shape='tile' />,
42
+ );
43
+ const group = getByRole('radiogroup');
44
+ expect(group.className).toContain('grid');
45
+ expect(group.getAttribute('style')).toContain('repeat(2, max-content)');
46
+ expect(radios(getAllByRole)[1]?.className).toContain('rounded-sm');
47
+ });
48
+
49
+ test('automatic (default): picking an unchecked swatch reports it; the checked one does not report again', () => {
50
+ const picked: string[] = [];
51
+ const { getAllByRole } = render(<SwatchPicker options={OPTIONS} value='2' onChange={(v) => picked.push(v)} />);
52
+ fireEvent.click(radios(getAllByRole)[3] as HTMLElement);
53
+ fireEvent.click(radios(getAllByRole)[2] as HTMLElement);
54
+ expect(picked).toEqual(['3']);
55
+ });
56
+
57
+ test('🔴 manual: an arrow key moves focus WITHOUT picking (a format menu would apply and close on it)', async () => {
58
+ const picked: string[] = [];
59
+ const { getAllByRole } = render(
60
+ <SwatchPicker options={OPTIONS} value='1' activationMode='manual' onChange={(v) => picked.push(v)} />,
61
+ );
62
+ const red = radios(getAllByRole)[1] as HTMLElement;
63
+ act(() => red.focus());
64
+ fireEvent.keyDown(red, { key: 'ArrowRight' });
65
+ await tick();
66
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Green');
67
+ fireEvent.keyUp(document.activeElement as HTMLElement, { key: 'ArrowRight' });
68
+ expect(picked).toEqual([]);
69
+ });
70
+
71
+ test('automatic: the same arrow key DOES pick — the radio pattern, which manual opts out of', async () => {
72
+ const picked: string[] = [];
73
+ const { getAllByRole } = render(<SwatchPicker options={OPTIONS} value='1' onChange={(v) => picked.push(v)} />);
74
+ const red = radios(getAllByRole)[1] as HTMLElement;
75
+ act(() => red.focus());
76
+ fireEvent.keyDown(red, { key: 'ArrowRight' });
77
+ await tick();
78
+ fireEvent.keyUp(document.activeElement as HTMLElement, { key: 'ArrowRight' });
79
+ expect(picked).toEqual(['2']);
80
+ });
81
+
82
+ test('🔴 manual: Enter picks the focused swatch, and a click on the CHECKED swatch reports it again', () => {
83
+ const picked: string[] = [];
84
+ const { getAllByRole } = render(
85
+ <SwatchPicker options={OPTIONS} value='1' activationMode='manual' onChange={(v) => picked.push(v)} />,
86
+ );
87
+ fireEvent.keyDown(radios(getAllByRole)[3] as HTMLElement, { key: 'Enter' });
88
+ fireEvent.click(radios(getAllByRole)[1] as HTMLElement);
89
+ fireEvent.click(radios(getAllByRole)[0] as HTMLElement);
90
+ expect(picked).toEqual(['3', '1', '']);
91
+ });
92
+ });
@@ -1,80 +1,166 @@
1
1
  import * as RadioGroup from '@radix-ui/react-radio-group';
2
2
  import { Check } from 'lucide-react';
3
+ import { type CSSProperties, useRef } from 'react';
3
4
  import { cn } from '../lib/cn.js';
4
5
  import { TAP_TARGET_SQUARE } from '../lib/tapTarget.js';
6
+ import { Tooltip } from './Tooltip.js';
5
7
 
6
- export interface SwatchOption<T extends string = string> {
8
+ interface SwatchOptionBase<T extends string> {
7
9
  /** Stable value reported by `onChange` (an id, an index-as-string, a hex…). */
8
10
  value: T;
9
- /** Any CSS color — hex, `var(--chart-1)`, `oklch(…)`. Fills the swatch. */
10
- color: string;
11
- /** Accessible name; defaults to the value. */
11
+ /** Accessible name, and the tooltip; defaults to the value. */
12
12
  label?: string;
13
13
  disabled?: boolean;
14
14
  }
15
15
 
16
+ /** One swatch: a colour — or, with `none`, the "no colour" choice, drawn as the ground struck through. */
17
+ export type SwatchOption<T extends string = string> = SwatchOptionBase<T> &
18
+ (
19
+ | {
20
+ /** Any CSS color — hex, `var(--chart-1)`, `oklch(…)`. Fills the swatch. */
21
+ color: string;
22
+ none?: undefined;
23
+ }
24
+ | {
25
+ /** The "no colour" swatch (a fill cleared, an accent removed): the card ground with a slash. */
26
+ none: true;
27
+ color?: undefined;
28
+ }
29
+ );
30
+
16
31
  export interface SwatchPickerProps<T extends string = string> {
17
32
  options: SwatchOption<T>[];
18
33
  value: T | undefined;
19
34
  onChange: (value: T) => void;
20
35
  /** Swatch diameter: `sm` 1.5rem, `md` (default) 1.75rem, `lg` 2.25rem. */
21
36
  size?: 'sm' | 'md' | 'lg';
37
+ /** `dot` (default) is round; `tile` is a rounded square — a palette's chip (Excel's colour grid). */
38
+ shape?: 'dot' | 'tile';
39
+ /** Lay the swatches out as a grid this many across (a 2-D palette) instead of one wrapping row. */
40
+ columns?: number;
41
+ /**
42
+ * `automatic` (default): the arrow keys move the choice, as a radio group's do — right for a
43
+ * value being SET (an accent, a series colour). `manual`: the arrows only move focus, and
44
+ * Enter, Space or a click picks — including the swatch already checked, which reports again.
45
+ * Right for a palette that APPLIES a colour (a format menu, a selection's toolbar), where every
46
+ * arrow press would otherwise be an edit and the first one would close the menu. The words are
47
+ * Radix Tabs' `activationMode`.
48
+ */
49
+ activationMode?: 'automatic' | 'manual';
22
50
  /** Accessible name for the group (default "Color"). */
23
51
  'aria-label'?: string;
24
52
  className?: string;
25
53
  }
26
54
 
27
55
  const SIZE = { sm: 'size-6', md: 'size-7', lg: 'size-9' } as const;
56
+ const SHAPE = { dot: 'rounded-full', tile: 'rounded-sm' } as const;
57
+ /** "No colour": the card ground, struck through corner to corner in the destructive hue. */
58
+ const NONE_FILL =
59
+ 'linear-gradient(135deg, transparent calc(50% - 1px), var(--destructive) calc(50% - 1px) calc(50% + 1px), transparent calc(50% + 1px)), var(--card)';
28
60
 
29
61
  /**
30
62
  * A discrete color-swatch radio group — pick ONE color from a fixed palette
31
- * (chart series colors, namespace hues, label tints). Radix `RadioGroup`
32
- * supplies the radiogroup ARIA pattern, roving tabindex, and arrow-key
33
- * navigation; the check glyph marks the selection.
63
+ * (chart series colors, namespace hues, label tints, a format menu's grid). Radix
64
+ * `RadioGroup` supplies the radiogroup ARIA pattern, roving tabindex, and arrow-key
65
+ * navigation; the check glyph and a ring mark the selection, and each swatch names
66
+ * itself in the belt's `Tooltip` — on focus as well as hover, which a native `title`
67
+ * never does (DESIGN.md §4, `no-raw-title-attr`).
34
68
  *
35
69
  * This is the shared primitive for every "row of color dots" UI. For the nav
36
70
  * accent picker (presets + custom hex + reset) see `ColorSwatchGrid`, which
37
- * composes this with a native color input.
71
+ * composes this with a native color input; for an accent row with an any-colour
72
+ * well, `ColorSwatches`.
73
+ *
74
+ * The checked ring keys off `aria-checked`, not Radix's `data-state`: the tooltip's
75
+ * own trigger writes `data-state` (open/closed) onto the same element.
38
76
  */
39
77
  export function SwatchPicker<T extends string = string>({
40
78
  options,
41
79
  value,
42
80
  onChange,
43
81
  size = 'md',
82
+ shape = 'dot',
83
+ columns,
84
+ activationMode = 'automatic',
44
85
  'aria-label': ariaLabel = 'Color',
45
86
  className,
46
87
  }: SwatchPickerProps<T>) {
88
+ const manual = activationMode === 'manual';
89
+ // Radix checks the radio an arrow key lands on by calling `click()` from inside its focus
90
+ // handler. In manual mode that one click is vetoed: our `onFocus` runs first and raises this
91
+ // flag until the focus event's dispatch is over, and `onClick` refuses Radix's check while it
92
+ // is up. (Preventing the FOCUS would skip it too, but the tooltip reads that as "do not open",
93
+ // and the arrow keys are exactly when a swatch's name is wanted.)
94
+ const focusing = useRef(false);
95
+ const grid: CSSProperties | undefined = columns ? { gridTemplateColumns: `repeat(${columns}, max-content)` } : undefined;
47
96
  return (
48
97
  <RadioGroup.Root
49
98
  aria-label={ariaLabel}
50
99
  value={value ?? ''}
51
100
  onValueChange={(v) => onChange(v as T)}
52
- className={cn('flex flex-wrap items-center gap-1.5', className)}
101
+ data-slot='swatch-picker'
102
+ style={grid}
103
+ className={cn(columns ? 'grid' : 'flex flex-wrap', 'items-center gap-1.5', className)}
53
104
  >
54
- {options.map((opt) => (
55
- <RadioGroup.Item
56
- key={opt.value}
57
- value={opt.value}
58
- disabled={opt.disabled}
59
- aria-label={opt.label ?? opt.value}
60
- title={opt.label ?? opt.value}
61
- style={{ backgroundColor: opt.color }}
62
- className={cn(
63
- 'inline-flex items-center justify-center rounded-full border border-border/40 transition-transform',
64
- SIZE[size],
65
- // A finger needs 44px (DESIGN.md §5): the swatch itself grows on a coarse pointer, since
66
- // the colour IS the button. A 28px one failed e2e/touch-targets (forge task 2514).
67
- TAP_TARGET_SQUARE,
68
- 'hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
69
- 'data-[state=checked]:ring-2 data-[state=checked]:ring-foreground data-[state=checked]:ring-offset-1',
70
- 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:scale-100',
71
- )}
72
- >
73
- <RadioGroup.Indicator asChild>
74
- <Check className='size-3.5 text-white mix-blend-difference' aria-hidden />
75
- </RadioGroup.Indicator>
76
- </RadioGroup.Item>
77
- ))}
105
+ {options.map((opt) => {
106
+ const name = opt.label ?? opt.value;
107
+ return (
108
+ <Tooltip key={opt.value} content={name}>
109
+ <RadioGroup.Item
110
+ value={opt.value}
111
+ disabled={opt.disabled}
112
+ aria-label={name}
113
+ data-swatch={opt.none ? 'none' : opt.value}
114
+ style={{ background: opt.none ? NONE_FILL : opt.color }}
115
+ onFocus={
116
+ manual
117
+ ? () => {
118
+ focusing.current = true;
119
+ queueMicrotask(() => {
120
+ focusing.current = false;
121
+ });
122
+ }
123
+ : undefined
124
+ }
125
+ onClick={
126
+ manual
127
+ ? (event) => {
128
+ if (focusing.current) event.preventDefault();
129
+ else if (opt.value === value) onChange(opt.value);
130
+ }
131
+ : undefined
132
+ }
133
+ onKeyDown={
134
+ manual
135
+ ? (event) => {
136
+ if (event.key !== 'Enter') return;
137
+ event.preventDefault();
138
+ onChange(opt.value);
139
+ }
140
+ : undefined
141
+ }
142
+ className={cn(
143
+ 'inline-flex items-center justify-center border border-border/40 transition-transform',
144
+ SIZE[size],
145
+ SHAPE[shape],
146
+ // A finger needs 44px (DESIGN.md §5): the swatch itself grows on a coarse pointer, since
147
+ // the colour IS the button. A 28px one failed e2e/touch-targets (forge task 2514).
148
+ TAP_TARGET_SQUARE,
149
+ 'hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
150
+ 'aria-checked:ring-2 aria-checked:ring-foreground aria-checked:ring-offset-1',
151
+ 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:scale-100',
152
+ )}
153
+ >
154
+ {/* The slash IS the "no colour" mark; a check over it would read as a second glyph. */}
155
+ {opt.none ? null : (
156
+ <RadioGroup.Indicator asChild>
157
+ <Check className='size-3.5 text-white mix-blend-difference' aria-hidden />
158
+ </RadioGroup.Indicator>
159
+ )}
160
+ </RadioGroup.Item>
161
+ </Tooltip>
162
+ );
163
+ })}
78
164
  </RadioGroup.Root>
79
165
  );
80
166
  }