cursedbelt 5.78.0 → 5.79.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 (138) hide show
  1. package/dist/react/canvas-hud/HudControls.d.ts +8 -2
  2. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
  3. package/dist/react/canvas-hud/HudControls.js +6 -3
  4. package/dist/react/canvas-hud/HudControls.js.map +1 -1
  5. package/dist/react/components/AppShell.d.ts +14 -0
  6. package/dist/react/components/AppShell.d.ts.map +1 -1
  7. package/dist/react/components/AppShell.js +7 -31
  8. package/dist/react/components/AppShell.js.map +1 -1
  9. package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
  10. package/dist/react/components/ChipFilterStrip.js +2 -18
  11. package/dist/react/components/ChipFilterStrip.js.map +1 -1
  12. package/dist/react/components/KanbanBoard.d.ts +55 -0
  13. package/dist/react/components/KanbanBoard.d.ts.map +1 -0
  14. package/dist/react/components/KanbanBoard.js +89 -0
  15. package/dist/react/components/KanbanBoard.js.map +1 -0
  16. package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
  17. package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
  18. package/dist/react/components/ResponsiveWorkbench.js +33 -4
  19. package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
  20. package/dist/react/components/SelectField.d.ts +18 -48
  21. package/dist/react/components/SelectField.d.ts.map +1 -1
  22. package/dist/react/components/SelectField.js +88 -11
  23. package/dist/react/components/SelectField.js.map +1 -1
  24. package/dist/react/components/SelectFieldImpl.d.ts +7 -0
  25. package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
  26. package/dist/react/components/SelectFieldImpl.js +26 -0
  27. package/dist/react/components/SelectFieldImpl.js.map +1 -0
  28. package/dist/react/components/filterChip.d.ts +8 -0
  29. package/dist/react/components/filterChip.d.ts.map +1 -0
  30. package/dist/react/components/filterChip.js +20 -0
  31. package/dist/react/components/filterChip.js.map +1 -0
  32. package/dist/react/hooks/pointerDnd.d.ts +37 -3
  33. package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
  34. package/dist/react/hooks/pointerDnd.js +101 -9
  35. package/dist/react/hooks/pointerDnd.js.map +1 -1
  36. package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
  37. package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
  38. package/dist/react/hooks/pragmaticDnd.js +9 -3
  39. package/dist/react/hooks/pragmaticDnd.js.map +1 -1
  40. package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
  41. package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
  42. package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
  43. package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
  44. package/dist/react/index.d.ts +3 -0
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +3 -0
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/lib/selectField.d.ts +72 -0
  49. package/dist/react/lib/selectField.d.ts.map +1 -0
  50. package/dist/react/lib/selectField.js +23 -0
  51. package/dist/react/lib/selectField.js.map +1 -0
  52. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  53. package/dist/react/media/VideoChapterEditor.js +2 -1
  54. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  55. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  56. package/dist/react/media/VideoCutEditor.js +2 -1
  57. package/dist/react/media/VideoCutEditor.js.map +1 -1
  58. package/dist/react/media/denseGallery.css +2 -0
  59. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  60. package/dist/react/media-gallery/MediaGallery.js +6 -7
  61. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  62. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
  63. package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
  64. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
  65. package/dist/react/nav/CommandBar.d.ts.map +1 -1
  66. package/dist/react/nav/CommandBar.js +2 -1
  67. package/dist/react/nav/CommandBar.js.map +1 -1
  68. package/dist/react/palette/CommandPalette.d.ts +15 -1
  69. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  70. package/dist/react/palette/CommandPalette.js +58 -8
  71. package/dist/react/palette/CommandPalette.js.map +1 -1
  72. package/dist/react/palette/Kbd.d.ts +15 -0
  73. package/dist/react/palette/Kbd.d.ts.map +1 -0
  74. package/dist/react/palette/Kbd.js +17 -0
  75. package/dist/react/palette/Kbd.js.map +1 -0
  76. package/dist/react/palette/ShortcutSheet.d.ts +30 -0
  77. package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
  78. package/dist/react/palette/ShortcutSheet.js +15 -0
  79. package/dist/react/palette/ShortcutSheet.js.map +1 -0
  80. package/dist/react/palette/commandSources.d.ts +14 -0
  81. package/dist/react/palette/commandSources.d.ts.map +1 -1
  82. package/dist/react/palette/commandSources.js +11 -1
  83. package/dist/react/palette/commandSources.js.map +1 -1
  84. package/dist/react/palette/index.d.ts +4 -2
  85. package/dist/react/palette/index.d.ts.map +1 -1
  86. package/dist/react/palette/index.js +2 -0
  87. package/dist/react/palette/index.js.map +1 -1
  88. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  89. package/dist/styles-areas/canvas-hud.css +2 -2
  90. package/dist/styles-areas/kanban.css +31 -0
  91. package/dist/styles-areas/palette.css +3 -3
  92. package/dist/styles-areas/workbench.css +15 -13
  93. package/dist/styles-utilities.css +42 -0
  94. package/package.json +3 -2
  95. package/scripts/guardrails-baseline.json +2 -1
  96. package/scripts/guardrailsEnforce.spec.ts +62 -0
  97. package/scripts/guardrailsEnforce.ts +77 -6
  98. package/scripts/styleAreas.ts +2 -0
  99. package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
  100. package/src/react/canvas-hud/HudControls.tsx +33 -9
  101. package/src/react/components/AppShell.spec.tsx +40 -0
  102. package/src/react/components/AppShell.tsx +25 -27
  103. package/src/react/components/ChipFilterStrip.tsx +2 -38
  104. package/src/react/components/KanbanBoard.spec.tsx +89 -0
  105. package/src/react/components/KanbanBoard.tsx +270 -0
  106. package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
  107. package/src/react/components/ResponsiveWorkbench.tsx +69 -4
  108. package/src/react/components/SelectField.spec.tsx +117 -0
  109. package/src/react/components/SelectField.tsx +131 -166
  110. package/src/react/components/SelectFieldImpl.tsx +140 -0
  111. package/src/react/components/filterChip.tsx +43 -0
  112. package/src/react/hooks/pointerDnd.spec.ts +89 -3
  113. package/src/react/hooks/pointerDnd.ts +103 -8
  114. package/src/react/hooks/pragmaticDnd.ts +13 -2
  115. package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
  116. package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
  117. package/src/react/index.ts +3 -0
  118. package/src/react/lib/floatingStyles.spec.ts +1 -1
  119. package/src/react/lib/selectField.ts +96 -0
  120. package/src/react/media/VideoChapterEditor.tsx +3 -2
  121. package/src/react/media/VideoCutEditor.tsx +3 -2
  122. package/src/react/media/denseGallery.css +2 -0
  123. package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
  124. package/src/react/media-gallery/MediaGallery.tsx +16 -18
  125. package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
  126. package/src/react/nav/CommandBar.tsx +2 -3
  127. package/src/react/palette/CommandPalette.spec.tsx +73 -0
  128. package/src/react/palette/CommandPalette.tsx +153 -13
  129. package/src/react/palette/Kbd.tsx +46 -0
  130. package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
  131. package/src/react/palette/ShortcutSheet.tsx +110 -0
  132. package/src/react/palette/commandSources.tsx +30 -1
  133. package/src/react/palette/index.ts +9 -0
  134. package/src/styles-areas/canvas-hud.css +2 -2
  135. package/src/styles-areas/kanban.css +31 -0
  136. package/src/styles-areas/palette.css +3 -3
  137. package/src/styles-areas/workbench.css +15 -13
  138. package/src/styles-utilities.css +42 -0
@@ -0,0 +1,117 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { useState } from 'react';
6
+ import { SelectField } from './SelectField.js';
7
+
8
+ afterEach(cleanup);
9
+
10
+ /** Radix's trigger carries `dir` (from its DirectionProvider); the light one draws none. */
11
+ const isRadix = (trigger: HTMLElement) => trigger.hasAttribute('dir');
12
+
13
+ const OPTIONS = [
14
+ { value: 'title', label: 'Title' },
15
+ { value: 'artist', label: 'Artist' },
16
+ ];
17
+
18
+ /** A copy of `SelectField.tsx` with its OWN module state — nothing has loaded its Radix chunk yet,
19
+ * so its first commit is the light trigger even though the test preload warmed the shared one. */
20
+ async function coldSelectField(): Promise<typeof SelectField> {
21
+ const mod = (await import(`./SelectField.tsx?cold=${Math.random()}`)) as {
22
+ SelectField: typeof SelectField;
23
+ };
24
+ return mod.SelectField;
25
+ }
26
+
27
+ function Picker({ Field, onPick }: { Field: typeof SelectField; onPick?: (v: string) => void }) {
28
+ const [value, setValue] = useState('artist');
29
+ return (
30
+ <Field
31
+ aria-label='Sort by'
32
+ value={value}
33
+ onValueChange={(v) => {
34
+ setValue(v);
35
+ onPick?.(v);
36
+ }}
37
+ options={OPTIONS}
38
+ />
39
+ );
40
+ }
41
+
42
+ /**
43
+ * A BYTE contract as much as a behavioural one: a SelectField on an app's entry chunk hoisted
44
+ * Radix Select there (+29.20 kB gzip in music, forge task 3096), so the Radix select is its own
45
+ * chunk and this file is a trigger.
46
+ */
47
+ describe('SelectField (lazy)', () => {
48
+ test('the wrapper imports no Radix, and loads the implementation dynamically', () => {
49
+ const src = readFileSync(join(import.meta.dir, 'SelectField.tsx'), 'utf8');
50
+ expect(src).not.toMatch(/from\s*['"]@radix-ui\//);
51
+ expect(src).not.toMatch(/from\s*['"]\.\/SelectFieldImpl(\.js)?['"]/);
52
+ expect(src).toContain("import('./SelectFieldImpl.js')");
53
+ });
54
+
55
+ test('the light trigger is the same box as the real one: role, label, classes, placeholder', async () => {
56
+ const Cold = await coldSelectField();
57
+ const light = render(<Cold aria-label='Sort by' value='artist' options={OPTIONS} className='w-40' />);
58
+ const lightTrigger = light.getByRole('combobox', { name: 'Sort by' });
59
+ expect(isRadix(lightTrigger)).toBe(false);
60
+ const lightBox = { text: lightTrigger.textContent, cls: lightTrigger.className };
61
+ light.unmount();
62
+
63
+ const real = render(<SelectField aria-label='Sort by' value='artist' options={OPTIONS} className='w-40' />);
64
+ const realTrigger = real.getByRole('combobox', { name: 'Sort by' });
65
+ expect(isRadix(realTrigger)).toBe(true);
66
+ expect(lightBox).toEqual({ text: realTrigger.textContent, cls: realTrigger.className });
67
+ expect(lightBox.text).toBe('Artist');
68
+ real.unmount();
69
+
70
+ const empty = render(<Cold aria-label='Sort by' placeholder='Pick one' options={OPTIONS} />);
71
+ const emptyTrigger = empty.getByRole('combobox', { name: 'Sort by' });
72
+ expect(emptyTrigger.textContent).toBe('Pick one');
73
+ expect(emptyTrigger.hasAttribute('data-placeholder')).toBe(true);
74
+ });
75
+
76
+ test('Enter on the light trigger is not lost: the real select mounts open and the pick lands', async () => {
77
+ const Cold = await coldSelectField();
78
+ const picks: string[] = [];
79
+ render(<Picker Field={Cold} onPick={(v) => picks.push(v)} />);
80
+ const trigger = screen.getByRole('combobox', { name: 'Sort by' });
81
+ expect(isRadix(trigger)).toBe(false);
82
+ fireEvent.keyDown(trigger, { key: 'Enter' });
83
+ fireEvent.click(await screen.findByRole('option', { name: 'Title' }));
84
+ expect(picks).toEqual(['title']);
85
+ await waitFor(() => expect(screen.getByRole('combobox', { name: 'Sort by' }).textContent).toBe('Title'));
86
+ });
87
+
88
+ test('a mouse press on the light trigger opens the list', async () => {
89
+ const Cold = await coldSelectField();
90
+ render(<Picker Field={Cold} />);
91
+ fireEvent.pointerDown(screen.getByRole('combobox', { name: 'Sort by' }), {
92
+ pointerType: 'mouse',
93
+ button: 0,
94
+ });
95
+ expect(await screen.findByRole('listbox')).toBeTruthy();
96
+ });
97
+
98
+ test('focus held on the light trigger moves to the real one when it lands', async () => {
99
+ const Cold = await coldSelectField();
100
+ render(<Picker Field={Cold} />);
101
+ const light = screen.getByRole('combobox', { name: 'Sort by' });
102
+ expect(isRadix(light)).toBe(false);
103
+ light.focus();
104
+ await waitFor(() =>
105
+ expect(isRadix(screen.getByRole('combobox', { name: 'Sort by' }))).toBe(true),
106
+ );
107
+ expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'Sort by' }));
108
+ });
109
+
110
+ test('once warm, the first commit is the real select — the keyboard path tests rely on', () => {
111
+ render(<Picker Field={SelectField} />);
112
+ const trigger = screen.getByRole('combobox', { name: 'Sort by' });
113
+ expect(isRadix(trigger)).toBe(true);
114
+ fireEvent.keyDown(trigger, { key: 'Enter' });
115
+ expect(screen.getByRole('option', { name: 'Title' })).toBeTruthy();
116
+ });
117
+ });
@@ -1,64 +1,40 @@
1
- import * as RSelect from '@radix-ui/react-select';
2
- import { Check, ChevronDown, ChevronUp } from 'lucide-react';
3
- import type { ReactNode } from 'react';
4
- import { cn } from '../lib/cn.js';
5
- import { useFloatingContainer } from '../lib/floatingContainer.js';
6
- import { type ControlSize, choiceFieldWidth, declareFieldWidth, fieldControlClass } from '../lib/field.js';
1
+ import { ChevronDown } from 'lucide-react';
2
+ import { type ComponentType, useEffect, useRef, useState } from 'react';
3
+ import { cn, RAW_BUTTON } from '../lib/cn.js';
4
+ import { choiceFieldWidth, declareFieldWidth } from '../lib/field.js';
7
5
  import {
8
- FLOATING_ANIMATION,
9
- FLOATING_COLLISION_PADDING,
10
- FLOATING_CONTENT_BASE,
11
- FLOATING_ITEM_BASE,
12
- } from '../lib/floatingStyles.js';
6
+ SELECT_OPEN_KEYS,
7
+ SELECT_VALUE_CLASS,
8
+ type SelectFieldImplProps,
9
+ type SelectFieldProps,
10
+ selectGroupsOf,
11
+ selectShowsPlaceholder,
12
+ selectTriggerClass,
13
+ } from '../lib/selectField.js';
13
14
 
14
- export interface SelectOption {
15
- value: string;
16
- label: ReactNode;
17
- description?: ReactNode;
18
- disabled?: boolean;
19
- }
15
+ export type { SelectFieldProps, SelectGroup, SelectOption } from '../lib/selectField.js';
20
16
 
21
- export interface SelectGroup {
22
- label?: string;
23
- options: SelectOption[];
24
- }
17
+ type SelectFieldImplComponent = ComponentType<SelectFieldImplProps>;
18
+
19
+ let loadedImpl: SelectFieldImplComponent | null = null;
20
+ let pendingImpl: Promise<SelectFieldImplComponent> | null = null;
25
21
 
26
- export interface SelectFieldProps {
27
- value?: string;
28
- onValueChange?: (value: string) => void;
29
- options?: SelectOption[];
30
- groups?: SelectGroup[];
31
- placeholder?: string;
32
- disabled?: boolean;
33
- /** Use the full field style (16px, rounded-md, border, focus ring). Default true. */
34
- fieldStyle?: boolean;
35
- /** Trigger height on the shared control-height scale (`--control-h-*`, task
36
- * cbux-41). Default `md`. No-op when `fieldStyle={false}`. */
37
- size?: ControlSize;
38
- /** Forwarded to the trigger so a visible `<label htmlFor>` can target it. */
39
- id?: string;
40
- name?: string;
41
- required?: boolean;
42
- className?: string;
43
- 'aria-label'?: string;
44
- 'aria-labelledby'?: string;
45
- /**
46
- * What to say when there is nothing to choose — *"No lists yet"*.
47
- *
48
- * 🔴 Required in practice for any control whose options come from DATA rather than
49
- * from a constant: an empty popup is a dead end that reads as a broken control, and
50
- * the `choice-needs-empty-state` guardrail reports one. Rendered as plain text, not
51
- * as an item, so it can never be picked or keyboard-focused.
52
- */
53
- emptyLabel?: ReactNode;
54
- /** The second line — what belongs in this list, or how to get one. */
55
- emptyHint?: ReactNode;
56
- /** Set by {@link Field} for inline validation — paints the field-token invalid
57
- * border (via `aria-invalid`) and links the description/error to the trigger. */
58
- 'aria-invalid'?: boolean | 'true' | 'false';
59
- 'aria-describedby'?: string;
22
+ /** Fetch the Radix implementation once per page. Exported for tests and for a consumer that wants
23
+ * to warm it explicitly. */
24
+ export function preloadSelectField(): Promise<SelectFieldImplComponent> {
25
+ if (!pendingImpl) {
26
+ pendingImpl = import('./SelectFieldImpl.js').then((m) => {
27
+ loadedImpl = m.default;
28
+ return m.default;
29
+ });
30
+ }
31
+ return pendingImpl;
60
32
  }
61
33
 
34
+ // Start the fetch when this module is evaluated, so the implementation is normally there before
35
+ // the first field mounts and no trigger is ever swapped (Tooltip.tsx has the history).
36
+ if (typeof window !== 'undefined') void preloadSelectField();
37
+
62
38
  /**
63
39
  * Portal-based select control on Radix Select. Replaces the native `<select>`
64
40
  * wrapper. Fully styleable, keyboard-navigable, a11y-correct, and never clipped by
@@ -71,122 +47,111 @@ export interface SelectFieldProps {
71
47
  * is true (the default for form fields), giving it the same control-height token +
72
48
  * rounded-md as Input at the same `size`. Pass `fieldStyle={false}` + a `className`
73
49
  * for a bespoke trigger.
50
+ *
51
+ * ── Why this file is a trigger and not the select ───────────────────────────
52
+ * 🔴 Radix Select (with its popper, focus scope and remove-scroll) is ~29 kB gzip, and a
53
+ * static import puts it in whatever chunk renders a field. In a lazy page that is a few kB;
54
+ * in an app's ENTRY it hoisted the Radix code a dozen lazy chunks had shared: music's library
55
+ * Sort by took `index-*.js` from 203 to 232 kB gzip and was reverted (forge task 3096). So the
56
+ * Radix select is `SelectFieldImpl`, a dynamic `import()`, and this file draws the same trigger
57
+ * box — same classes, `role="combobox"`, the chosen option's label, the chevron — until it
58
+ * lands. A press, Enter, Space or an arrow that beats the fetch is not lost: the real select
59
+ * mounts open; focus held on the light trigger moves to the real one.
74
60
  */
75
- export function SelectField({
76
- value,
77
- onValueChange,
78
- options = [],
79
- groups = [],
80
- placeholder = 'Select…',
81
- disabled,
82
- fieldStyle = true,
83
- size = 'md',
84
- id,
85
- name,
86
- required,
87
- className,
88
- emptyLabel,
89
- emptyHint,
90
- 'aria-label': ariaLabel,
91
- 'aria-labelledby': ariaLabelledBy,
92
- 'aria-invalid': ariaInvalid,
93
- 'aria-describedby': ariaDescribedBy,
94
- }: SelectFieldProps) {
95
- const allGroups: SelectGroup[] =
96
- groups.length > 0 ? groups : options.length > 0 ? [{ options }] : [];
97
- const empty = allGroups.every((group) => group.options.length === 0);
98
- const container = useFloatingContainer();
61
+ export function SelectField(props: SelectFieldProps) {
62
+ // `() => loadedImpl`: a component IS a function, and `useState(fn)` would call it.
63
+ const [Impl, setImpl] = useState<SelectFieldImplComponent | null>(() => loadedImpl);
64
+ const openRef = useRef(false);
65
+ const triggerRef = useRef<HTMLButtonElement>(null);
66
+ const pointerTypeRef = useRef('mouse');
67
+
68
+ useEffect(() => {
69
+ if (Impl) return;
70
+ let alive = true;
71
+ void preloadSelectField().then((c) => {
72
+ if (alive) setImpl(() => c);
73
+ });
74
+ return () => {
75
+ alive = false;
76
+ };
77
+ }, [Impl]);
78
+
79
+ if (Impl) {
80
+ const focused = triggerRef.current !== null && triggerRef.current === document.activeElement;
81
+ return <Impl {...props} initialOpen={openRef.current} initialFocus={focused} />;
82
+ }
83
+
84
+ const {
85
+ value,
86
+ placeholder = 'Select…',
87
+ disabled,
88
+ fieldStyle,
89
+ size,
90
+ className,
91
+ id,
92
+ required,
93
+ 'aria-label': ariaLabel,
94
+ 'aria-labelledby': ariaLabelledBy,
95
+ 'aria-invalid': ariaInvalid,
96
+ 'aria-describedby': ariaDescribedBy,
97
+ } = props;
98
+ const showPlaceholder = selectShowsPlaceholder(value);
99
+ const chosen = showPlaceholder
100
+ ? undefined
101
+ : selectGroupsOf(props)
102
+ .flatMap((group) => group.options)
103
+ .find((opt) => opt.value === value);
104
+
105
+ const open = () => {
106
+ openRef.current = true;
107
+ // Already fetched by another field: swap now rather than on the effect's next tick.
108
+ if (loadedImpl) setImpl(() => loadedImpl);
109
+ };
99
110
 
100
111
  return (
101
- <RSelect.Root
102
- value={value}
103
- onValueChange={onValueChange}
112
+ <button
113
+ // guardrails-ignore no-raw-action-button: task 3096 — SelectField's light trigger until
114
+ // its Radix select loads, the same box SelectFieldImpl's trigger draws.
115
+ ref={triggerRef}
116
+ type='button'
117
+ role='combobox'
118
+ id={id}
119
+ aria-label={ariaLabel}
120
+ aria-labelledby={ariaLabelledBy}
121
+ aria-invalid={ariaInvalid}
122
+ aria-describedby={ariaDescribedBy}
123
+ aria-expanded={false}
124
+ aria-required={required}
125
+ aria-autocomplete='none'
126
+ data-state='closed'
104
127
  disabled={disabled}
105
- name={name}
106
- required={required}
128
+ data-disabled={disabled ? '' : undefined}
129
+ data-placeholder={showPlaceholder ? '' : undefined}
130
+ className={cn(RAW_BUTTON, selectTriggerClass({ fieldStyle, size, className }))}
131
+ // Radix's own split: a mouse opens on press, touch and pen on the click (so a scroll that
132
+ // starts on the field is not a pick).
133
+ onPointerDown={(e) => {
134
+ pointerTypeRef.current = e.pointerType;
135
+ if (e.pointerType === 'mouse' && e.button === 0 && !e.ctrlKey) {
136
+ e.preventDefault();
137
+ open();
138
+ }
139
+ }}
140
+ onClick={() => {
141
+ if (pointerTypeRef.current !== 'mouse') open();
142
+ }}
143
+ onKeyDown={(e) => {
144
+ if (SELECT_OPEN_KEYS.includes(e.key)) {
145
+ e.preventDefault();
146
+ open();
147
+ }
148
+ }}
107
149
  >
108
- <RSelect.Trigger
109
- id={id}
110
- aria-label={ariaLabel}
111
- aria-labelledby={ariaLabelledBy}
112
- aria-invalid={ariaInvalid}
113
- aria-describedby={ariaDescribedBy}
114
- className={cn(
115
- fieldStyle && fieldControlClass(size),
116
- 'flex items-center justify-between gap-2 data-[placeholder]:text-muted-foreground',
117
- className,
118
- )}
119
- >
120
- {/* One line, ellipsized, left-aligned: a long value (a deep path) used to wrap to three
121
- centred lines and spill out of a 36px trigger at 390px (Radix Themes' Select
122
- truncates the same way, forge task 2594). Radix's Value drops `className`, so the
123
- truncation lives on a wrapper. */}
124
- <span className='block min-w-0 flex-1 truncate text-left'>
125
- <RSelect.Value placeholder={placeholder} />
126
- </span>
127
- <RSelect.Icon asChild>
128
- <ChevronDown className='size-4 shrink-0 opacity-50' />
129
- </RSelect.Icon>
130
- </RSelect.Trigger>
131
- <RSelect.Portal container={container}>
132
- <RSelect.Content
133
- position='popper'
134
- sideOffset={4}
135
- collisionPadding={FLOATING_COLLISION_PADDING}
136
- className={cn(
137
- FLOATING_COLLISION_PADDING,
138
- FLOATING_CONTENT_BASE,
139
- FLOATING_ANIMATION,
140
- 'max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] p-1',
141
- )}
142
- >
143
- <RSelect.ScrollUpButton className='flex cursor-default items-center justify-center py-1'>
144
- <ChevronUp className='size-4' />
145
- </RSelect.ScrollUpButton>
146
- <RSelect.Viewport>
147
- {empty && (
148
- // Not a `RSelect.Item`: an empty state is a statement, not a choice, and
149
- // Radix would make it focusable and type-ahead-matchable.
150
- <div className='flex flex-col gap-0.5 px-2 py-2.5 text-left'>
151
- <span className='text-sm font-medium'>
152
- {emptyLabel ?? 'Nothing to choose from yet'}
153
- </span>
154
- {emptyHint && <span className='text-xs text-muted-foreground'>{emptyHint}</span>}
155
- </div>
156
- )}
157
- {allGroups.map((group, gi) => (
158
- <RSelect.Group key={group.label ?? gi}>
159
- {group.label && (
160
- <RSelect.Label className='px-2 py-1.5 text-xs font-semibold text-muted-foreground'>
161
- {group.label}
162
- </RSelect.Label>
163
- )}
164
- {group.options.map((opt) => (
165
- <RSelect.Item
166
- key={opt.value}
167
- value={opt.value}
168
- disabled={opt.disabled}
169
- className={cn(FLOATING_ITEM_BASE, 'pr-8')}
170
- >
171
- <RSelect.ItemIndicator className='absolute right-2 flex size-4 items-center justify-center'>
172
- <Check className='size-3' />
173
- </RSelect.ItemIndicator>
174
- <RSelect.ItemText>{opt.label}</RSelect.ItemText>
175
- {opt.description && (
176
- <span className='text-xs text-muted-foreground'>{opt.description}</span>
177
- )}
178
- </RSelect.Item>
179
- ))}
180
- {gi < allGroups.length - 1 && <RSelect.Separator className='my-1 h-px bg-border' />}
181
- </RSelect.Group>
182
- ))}
183
- </RSelect.Viewport>
184
- <RSelect.ScrollDownButton className='flex cursor-default items-center justify-center py-1'>
185
- <ChevronDown className='size-4' />
186
- </RSelect.ScrollDownButton>
187
- </RSelect.Content>
188
- </RSelect.Portal>
189
- </RSelect.Root>
150
+ <span className={SELECT_VALUE_CLASS}>
151
+ {showPlaceholder ? placeholder : chosen?.label}
152
+ </span>
153
+ <ChevronDown aria-hidden className='size-4 shrink-0 opacity-50' />
154
+ </button>
190
155
  );
191
156
  }
192
157
 
@@ -0,0 +1,140 @@
1
+ import * as RSelect from '@radix-ui/react-select';
2
+ import { Check, ChevronDown, ChevronUp } from 'lucide-react';
3
+ import { useEffect, useRef } from 'react';
4
+ import { cn, RAW_BUTTON } from '../lib/cn.js';
5
+ import { useFloatingContainer } from '../lib/floatingContainer.js';
6
+ import {
7
+ FLOATING_ANIMATION,
8
+ FLOATING_COLLISION_PADDING,
9
+ FLOATING_CONTENT_BASE,
10
+ FLOATING_ITEM_BASE,
11
+ } from '../lib/floatingStyles.js';
12
+ import {
13
+ SELECT_VALUE_CLASS,
14
+ type SelectFieldImplProps,
15
+ selectGroupsOf,
16
+ selectTriggerClass,
17
+ } from '../lib/selectField.js';
18
+
19
+ /**
20
+ * The Radix Select behind {@link SelectField} — loaded as its own chunk by `SelectField.tsx`, which
21
+ * says why. Not exported from the package: render `SelectField`.
22
+ */
23
+ export default function SelectFieldImpl({
24
+ value,
25
+ onValueChange,
26
+ options = [],
27
+ groups = [],
28
+ placeholder = 'Select…',
29
+ disabled,
30
+ fieldStyle = true,
31
+ size = 'md',
32
+ id,
33
+ name,
34
+ required,
35
+ className,
36
+ emptyLabel,
37
+ emptyHint,
38
+ 'aria-label': ariaLabel,
39
+ 'aria-labelledby': ariaLabelledBy,
40
+ 'aria-invalid': ariaInvalid,
41
+ 'aria-describedby': ariaDescribedBy,
42
+ initialOpen = false,
43
+ initialFocus = false,
44
+ }: SelectFieldImplProps) {
45
+ const allGroups = selectGroupsOf({ options, groups });
46
+ const empty = allGroups.every((group) => group.options.length === 0);
47
+ const container = useFloatingContainer();
48
+ const triggerRef = useRef<HTMLButtonElement>(null);
49
+
50
+ // The light trigger this replaced had the keyboard focus: keep it here, or it drops to <body>.
51
+ // An `initialOpen` select moves focus into its list itself, and hands it back on close.
52
+ useEffect(() => {
53
+ if (initialFocus && !initialOpen) triggerRef.current?.focus();
54
+ }, [initialFocus, initialOpen]);
55
+
56
+ return (
57
+ <RSelect.Root
58
+ value={value}
59
+ onValueChange={onValueChange}
60
+ disabled={disabled}
61
+ name={name}
62
+ required={required}
63
+ defaultOpen={initialOpen}
64
+ >
65
+ <RSelect.Trigger
66
+ ref={triggerRef}
67
+ id={id}
68
+ aria-label={ariaLabel}
69
+ aria-labelledby={ariaLabelledBy}
70
+ aria-invalid={ariaInvalid}
71
+ aria-describedby={ariaDescribedBy}
72
+ className={cn(RAW_BUTTON, selectTriggerClass({ fieldStyle, size, className }))}
73
+ >
74
+ <span className={SELECT_VALUE_CLASS}>
75
+ <RSelect.Value placeholder={placeholder} />
76
+ </span>
77
+ <RSelect.Icon asChild>
78
+ <ChevronDown className='size-4 shrink-0 opacity-50' />
79
+ </RSelect.Icon>
80
+ </RSelect.Trigger>
81
+ <RSelect.Portal container={container}>
82
+ <RSelect.Content
83
+ position='popper'
84
+ sideOffset={4}
85
+ collisionPadding={FLOATING_COLLISION_PADDING}
86
+ className={cn(
87
+ FLOATING_CONTENT_BASE,
88
+ FLOATING_ANIMATION,
89
+ 'max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] p-1',
90
+ )}
91
+ >
92
+ <RSelect.ScrollUpButton className='flex cursor-default items-center justify-center py-1'>
93
+ <ChevronUp className='size-4' />
94
+ </RSelect.ScrollUpButton>
95
+ <RSelect.Viewport>
96
+ {empty && (
97
+ // Not a `RSelect.Item`: an empty state is a statement, not a choice, and
98
+ // Radix would make it focusable and type-ahead-matchable.
99
+ <div className='flex flex-col gap-0.5 px-2 py-2.5 text-left'>
100
+ <span className='text-sm font-medium'>
101
+ {emptyLabel ?? 'Nothing to choose from yet'}
102
+ </span>
103
+ {emptyHint && <span className='text-xs text-muted-foreground'>{emptyHint}</span>}
104
+ </div>
105
+ )}
106
+ {allGroups.map((group, gi) => (
107
+ <RSelect.Group key={group.label ?? gi}>
108
+ {group.label && (
109
+ <RSelect.Label className='px-2 py-1.5 text-xs font-semibold text-muted-foreground'>
110
+ {group.label}
111
+ </RSelect.Label>
112
+ )}
113
+ {group.options.map((opt) => (
114
+ <RSelect.Item
115
+ key={opt.value}
116
+ value={opt.value}
117
+ disabled={opt.disabled}
118
+ className={cn(FLOATING_ITEM_BASE, 'pr-8')}
119
+ >
120
+ <RSelect.ItemIndicator className='absolute right-2 flex size-4 items-center justify-center'>
121
+ <Check className='size-3' />
122
+ </RSelect.ItemIndicator>
123
+ <RSelect.ItemText>{opt.label}</RSelect.ItemText>
124
+ {opt.description && (
125
+ <span className='text-xs text-muted-foreground'>{opt.description}</span>
126
+ )}
127
+ </RSelect.Item>
128
+ ))}
129
+ {gi < allGroups.length - 1 && <RSelect.Separator className='my-1 h-px bg-border' />}
130
+ </RSelect.Group>
131
+ ))}
132
+ </RSelect.Viewport>
133
+ <RSelect.ScrollDownButton className='flex cursor-default items-center justify-center py-1'>
134
+ <ChevronDown className='size-4' />
135
+ </RSelect.ScrollDownButton>
136
+ </RSelect.Content>
137
+ </RSelect.Portal>
138
+ </RSelect.Root>
139
+ );
140
+ }
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn, RAW_BUTTON } from '../lib/cn.js';
3
+ import { PRESS } from '../lib/press.js';
4
+
5
+ /*
6
+ * The pill `ChipFilterStrip` draws, and `KanbanBoard`'s column tray on a phone. Its own module,
7
+ * off every barrel and subpath: it is a part, not a component an app reaches for.
8
+ */
9
+
10
+ export interface ChipProps {
11
+ active: boolean;
12
+ onClick: () => void;
13
+ children: ReactNode;
14
+ }
15
+
16
+ export function Chip({ active, onClick, children }: ChipProps) {
17
+ return (
18
+ <button
19
+ // guardrails-ignore no-raw-action-button: presentational filter-chip toggle —
20
+ // reflects local filter state (aria-pressed), no destructive/mutating intent.
21
+ type='button'
22
+ onClick={onClick}
23
+ aria-pressed={active}
24
+ className={cn(
25
+ RAW_BUTTON,
26
+ // 44 px min tap target height via py + min-h (STANDARDS §4a). px-4 keeps every
27
+ // pill's internal padding identical regardless of label length.
28
+ 'inline-flex min-h-11 shrink-0 cursor-pointer items-center rounded-full px-4 text-sm font-medium',
29
+ PRESS,
30
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
31
+ // Soft active state — an accent tint + accent text (the standardized active
32
+ // treatment shared with nav items and the `accent` Badge tone), not a stark
33
+ // solid accent block. `text-accent-strong` (not plain `text-accent`) — the
34
+ // /15 tint only clears 4.43:1 with the base accent hue, under AA (cbux-101).
35
+ active
36
+ ? 'bg-accent/15 text-accent-strong ring-1 ring-inset ring-accent/30'
37
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
38
+ )}
39
+ >
40
+ {children}
41
+ </button>
42
+ );
43
+ }