cursedbelt 5.78.0 → 5.79.1

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 (143) 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/FileDropZone.d.ts.map +1 -1
  13. package/dist/react/components/FileDropZone.js +4 -1
  14. package/dist/react/components/FileDropZone.js.map +1 -1
  15. package/dist/react/components/KanbanBoard.d.ts +55 -0
  16. package/dist/react/components/KanbanBoard.d.ts.map +1 -0
  17. package/dist/react/components/KanbanBoard.js +89 -0
  18. package/dist/react/components/KanbanBoard.js.map +1 -0
  19. package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
  20. package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
  21. package/dist/react/components/ResponsiveWorkbench.js +33 -4
  22. package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
  23. package/dist/react/components/SelectField.d.ts +18 -48
  24. package/dist/react/components/SelectField.d.ts.map +1 -1
  25. package/dist/react/components/SelectField.js +88 -11
  26. package/dist/react/components/SelectField.js.map +1 -1
  27. package/dist/react/components/SelectFieldImpl.d.ts +7 -0
  28. package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
  29. package/dist/react/components/SelectFieldImpl.js +26 -0
  30. package/dist/react/components/SelectFieldImpl.js.map +1 -0
  31. package/dist/react/components/filterChip.d.ts +8 -0
  32. package/dist/react/components/filterChip.d.ts.map +1 -0
  33. package/dist/react/components/filterChip.js +20 -0
  34. package/dist/react/components/filterChip.js.map +1 -0
  35. package/dist/react/hooks/pointerDnd.d.ts +37 -3
  36. package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
  37. package/dist/react/hooks/pointerDnd.js +101 -9
  38. package/dist/react/hooks/pointerDnd.js.map +1 -1
  39. package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
  40. package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
  41. package/dist/react/hooks/pragmaticDnd.js +9 -3
  42. package/dist/react/hooks/pragmaticDnd.js.map +1 -1
  43. package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
  44. package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
  45. package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
  46. package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
  47. package/dist/react/index.d.ts +3 -0
  48. package/dist/react/index.d.ts.map +1 -1
  49. package/dist/react/index.js +3 -0
  50. package/dist/react/index.js.map +1 -1
  51. package/dist/react/lib/selectField.d.ts +72 -0
  52. package/dist/react/lib/selectField.d.ts.map +1 -0
  53. package/dist/react/lib/selectField.js +23 -0
  54. package/dist/react/lib/selectField.js.map +1 -0
  55. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  56. package/dist/react/media/VideoChapterEditor.js +2 -1
  57. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  58. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  59. package/dist/react/media/VideoCutEditor.js +2 -1
  60. package/dist/react/media/VideoCutEditor.js.map +1 -1
  61. package/dist/react/media/denseGallery.css +2 -0
  62. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  63. package/dist/react/media-gallery/MediaGallery.js +6 -7
  64. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  65. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
  66. package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
  67. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
  68. package/dist/react/nav/CommandBar.d.ts.map +1 -1
  69. package/dist/react/nav/CommandBar.js +2 -1
  70. package/dist/react/nav/CommandBar.js.map +1 -1
  71. package/dist/react/palette/CommandPalette.d.ts +15 -1
  72. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  73. package/dist/react/palette/CommandPalette.js +58 -8
  74. package/dist/react/palette/CommandPalette.js.map +1 -1
  75. package/dist/react/palette/Kbd.d.ts +15 -0
  76. package/dist/react/palette/Kbd.d.ts.map +1 -0
  77. package/dist/react/palette/Kbd.js +17 -0
  78. package/dist/react/palette/Kbd.js.map +1 -0
  79. package/dist/react/palette/ShortcutSheet.d.ts +30 -0
  80. package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
  81. package/dist/react/palette/ShortcutSheet.js +15 -0
  82. package/dist/react/palette/ShortcutSheet.js.map +1 -0
  83. package/dist/react/palette/commandSources.d.ts +14 -0
  84. package/dist/react/palette/commandSources.d.ts.map +1 -1
  85. package/dist/react/palette/commandSources.js +11 -1
  86. package/dist/react/palette/commandSources.js.map +1 -1
  87. package/dist/react/palette/index.d.ts +4 -2
  88. package/dist/react/palette/index.d.ts.map +1 -1
  89. package/dist/react/palette/index.js +2 -0
  90. package/dist/react/palette/index.js.map +1 -1
  91. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  92. package/dist/styles-areas/canvas-hud.css +2 -2
  93. package/dist/styles-areas/kanban.css +31 -0
  94. package/dist/styles-areas/palette.css +3 -3
  95. package/dist/styles-areas/workbench.css +15 -13
  96. package/dist/styles-utilities.css +42 -0
  97. package/package.json +4 -3
  98. package/scripts/guardrails-baseline.json +2 -1
  99. package/scripts/guardrailsEnforce.spec.ts +62 -0
  100. package/scripts/guardrailsEnforce.ts +77 -6
  101. package/scripts/styleAreas.ts +2 -0
  102. package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
  103. package/src/react/canvas-hud/HudControls.tsx +33 -9
  104. package/src/react/components/AppShell.spec.tsx +40 -0
  105. package/src/react/components/AppShell.tsx +25 -27
  106. package/src/react/components/ChipFilterStrip.tsx +2 -38
  107. package/src/react/components/FileDropZone.spec.tsx +16 -0
  108. package/src/react/components/FileDropZone.tsx +3 -1
  109. package/src/react/components/KanbanBoard.spec.tsx +89 -0
  110. package/src/react/components/KanbanBoard.tsx +270 -0
  111. package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
  112. package/src/react/components/ResponsiveWorkbench.tsx +69 -4
  113. package/src/react/components/SelectField.spec.tsx +117 -0
  114. package/src/react/components/SelectField.tsx +131 -166
  115. package/src/react/components/SelectFieldImpl.tsx +140 -0
  116. package/src/react/components/filterChip.tsx +43 -0
  117. package/src/react/hooks/pointerDnd.spec.ts +89 -3
  118. package/src/react/hooks/pointerDnd.ts +103 -8
  119. package/src/react/hooks/pragmaticDnd.ts +13 -2
  120. package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
  121. package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
  122. package/src/react/index.ts +3 -0
  123. package/src/react/lib/floatingStyles.spec.ts +1 -1
  124. package/src/react/lib/selectField.ts +96 -0
  125. package/src/react/media/VideoChapterEditor.tsx +3 -2
  126. package/src/react/media/VideoCutEditor.tsx +3 -2
  127. package/src/react/media/denseGallery.css +2 -0
  128. package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
  129. package/src/react/media-gallery/MediaGallery.tsx +16 -18
  130. package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
  131. package/src/react/nav/CommandBar.tsx +2 -3
  132. package/src/react/palette/CommandPalette.spec.tsx +73 -0
  133. package/src/react/palette/CommandPalette.tsx +153 -13
  134. package/src/react/palette/Kbd.tsx +46 -0
  135. package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
  136. package/src/react/palette/ShortcutSheet.tsx +110 -0
  137. package/src/react/palette/commandSources.tsx +30 -1
  138. package/src/react/palette/index.ts +9 -0
  139. package/src/styles-areas/canvas-hud.css +2 -2
  140. package/src/styles-areas/kanban.css +31 -0
  141. package/src/styles-areas/palette.css +3 -3
  142. package/src/styles-areas/workbench.css +15 -13
  143. package/src/styles-utilities.css +42 -0
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { Button } from './Button.js';
3
4
  import { ResponsiveWorkbench } from './ResponsiveWorkbench.js';
4
5
 
5
6
  // useMediaQuery reads window.matchMedia; stub it so the breakpoint is deterministic
@@ -168,4 +169,60 @@ describe('ResponsiveWorkbench — below breakpoint (mobile)', () => {
168
169
  );
169
170
  expect(getByText('+ Add')).toBeTruthy();
170
171
  });
172
+
173
+ test('sheets: the rails rise as bottom sheets and the bar floats as a handle, not a row', () => {
174
+ stubMatchMedia(false);
175
+ const { container } = render(
176
+ <ResponsiveWorkbench
177
+ mobilePanels='sheets'
178
+ right={<div>Inspector</div>}
179
+ rightOpen
180
+ onRightOpenChange={() => {}}
181
+ mobileUtilityBar={<Button tooltip='What is selected'>Details</Button>}
182
+ >
183
+ <div>Canvas</div>
184
+ </ResponsiveWorkbench>,
185
+ );
186
+ const handle = container.querySelector('[data-cb-workbench-sheet-handle]');
187
+ expect(handle?.textContent).toContain('Details');
188
+ expect(container.querySelector('[data-cb-workbench-utility-bar]')).toBeNull();
189
+ // Floated: absolutely placed over the main area, so the main area keeps the whole height.
190
+ expect(handle?.parentElement?.className).toContain('absolute');
191
+ expect(dialogs()[0]?.className).toContain('bottom-0');
192
+ });
193
+
194
+ test('sheets: a swipe up on the handle opens the right rail, and does not also click the button under it', () => {
195
+ stubMatchMedia(false);
196
+ const opened: string[] = [];
197
+ const clicked: string[] = [];
198
+ const { container, getByText } = render(
199
+ <ResponsiveWorkbench
200
+ mobilePanels='sheets'
201
+ left={<div>Palette</div>}
202
+ right={<div>Inspector</div>}
203
+ leftOpen={false}
204
+ onLeftOpenChange={(v) => v && opened.push('left')}
205
+ rightOpen={false}
206
+ onRightOpenChange={(v) => v && opened.push('right')}
207
+ mobileUtilityBar={
208
+ <Button tooltip='Flows and the legend' onClick={() => clicked.push('explore')}>Explore</Button>
209
+ }
210
+ >
211
+ <div>Canvas</div>
212
+ </ResponsiveWorkbench>,
213
+ );
214
+ const handle = container.querySelector('[data-cb-workbench-sheet-handle]') as HTMLElement;
215
+ const button = getByText('Explore');
216
+ fireEvent.pointerDown(button, { pointerId: 1, clientY: 300 });
217
+ fireEvent.pointerUp(button, { pointerId: 1, clientY: 240 });
218
+ fireEvent.click(button);
219
+ expect(opened).toEqual(['right']);
220
+ expect(clicked).toEqual([]);
221
+ // A plain tap is still a tap.
222
+ fireEvent.pointerDown(button, { pointerId: 2, clientY: 300 });
223
+ fireEvent.pointerUp(button, { pointerId: 2, clientY: 298 });
224
+ fireEvent.click(button);
225
+ expect(clicked).toEqual(['explore']);
226
+ expect(handle).not.toBeNull();
227
+ });
171
228
  });
@@ -1,4 +1,4 @@
1
- import { type ReactNode, useState } from 'react';
1
+ import { type ReactNode, useRef, useState } from 'react';
2
2
  import { useMediaQuery } from '../hooks/useMediaQuery.js';
3
3
  import { cn } from '../lib/cn.js';
4
4
  import { Drawer } from './Drawer.js';
@@ -71,9 +71,26 @@ export interface ResponsiveWorkbenchProps {
71
71
  */
72
72
  mobileUtilityBar?: ReactNode;
73
73
 
74
+ /**
75
+ * How the rails come in below the breakpoint.
76
+ *
77
+ * - `drawers` (default): each rail slides in from its own side, and `mobileUtilityBar` is a
78
+ * row under the main area.
79
+ * - `sheets`: each rail rises as a bottom sheet, and `mobileUtilityBar` FLOATS over the bottom
80
+ * of the main area as a handle (a grabber over the caller's buttons) instead of taking a row —
81
+ * a canvas keeps the whole height, the way Apple Maps' and Figma's phone panels do. Swiping
82
+ * the handle up opens `mobileSwipeOpens`.
83
+ */
84
+ mobilePanels?: 'drawers' | 'sheets';
85
+ /** The rail a swipe up on the `sheets` handle opens. Default `right` (or `left` when there is no right). */
86
+ mobileSwipeOpens?: 'left' | 'right';
87
+
74
88
  className?: string;
75
89
  }
76
90
 
91
+ /** Travel, in px, that turns a press on the sheets handle into a swipe up. */
92
+ const SWIPE_UP = 32;
93
+
77
94
  /**
78
95
  * The three-pane workbench (left rail / main / right rail) used by builder-style apps.
79
96
  * At/above `breakpoint` (default 1024, Tailwind `lg`) both rails render as persistent,
@@ -107,11 +124,20 @@ export function ResponsiveWorkbench({
107
124
  rightCollapsedRail,
108
125
  breakpoint = DEFAULT_BREAKPOINT,
109
126
  mobileUtilityBar,
127
+ mobilePanels = 'drawers',
128
+ mobileSwipeOpens,
110
129
  className,
111
130
  }: ResponsiveWorkbenchProps) {
112
131
  const isDesktop = useWorkbenchBreakpoint(breakpoint);
113
132
  const [leftOpen, setLeftOpen] = useOpenState(leftOpenProp, onLeftOpenChange, defaultLeftOpen);
114
133
  const [rightOpen, setRightOpen] = useOpenState(rightOpenProp, onRightOpenChange, defaultRightOpen);
134
+ const sheets = mobilePanels === 'sheets';
135
+
136
+ // The handle's swipe up. A swipe that starts on one of the caller's buttons must not ALSO click
137
+ // it (Explore opening on top of the sheet the swipe opened), so the click after a swipe is eaten.
138
+ const swipe = useRef<{ id: number; y: number } | null>(null);
139
+ const swiped = useRef(false);
140
+ const swipeTarget = mobileSwipeOpens ?? (right ? 'right' : 'left');
115
141
 
116
142
  return (
117
143
  // A workbench spans the screen by design — LAY-1's `full` row (canvas, studio, workbench),
@@ -135,7 +161,8 @@ export function ResponsiveWorkbench({
135
161
  <Drawer
136
162
  open={leftOpen}
137
163
  onClose={() => setLeftOpen(false)}
138
- side='left'
164
+ side={sheets ? 'bottom' : 'left'}
165
+ size={sheets ? 'lg' : undefined}
139
166
  title={leftTitle}
140
167
  scrollBody={false}
141
168
  >
@@ -167,7 +194,8 @@ export function ResponsiveWorkbench({
167
194
  <Drawer
168
195
  open={rightOpen}
169
196
  onClose={() => setRightOpen(false)}
170
- side='right'
197
+ side={sheets ? 'bottom' : 'right'}
198
+ size={sheets ? 'lg' : undefined}
171
199
  title={rightTitle}
172
200
  scrollBody={false}
173
201
  >
@@ -176,7 +204,44 @@ export function ResponsiveWorkbench({
176
204
  ))}
177
205
  </div>
178
206
 
179
- {!isDesktop && mobileUtilityBar && (
207
+ {!isDesktop && mobileUtilityBar && sheets && (
208
+ // Floats over the main area: the canvas runs to the bottom edge under it.
209
+ <div className='pointer-events-none absolute inset-x-3 bottom-[max(env(safe-area-inset-bottom,0px),0.75rem)] flex justify-center'>
210
+ <div
211
+ data-cb-workbench-sheet-handle=''
212
+ className={cn(
213
+ 'pointer-events-auto flex w-full max-w-md touch-none flex-col items-stretch gap-1.5',
214
+ 'rounded-2xl border border-border/60 bg-background/85 px-2 pt-1.5 pb-2 shadow-raised backdrop-blur-md',
215
+ )}
216
+ onPointerDown={(e) => {
217
+ swipe.current = { id: e.pointerId, y: e.clientY };
218
+ swiped.current = false;
219
+ }}
220
+ onPointerUp={(e) => {
221
+ const start = swipe.current;
222
+ swipe.current = null;
223
+ if (!start || start.id !== e.pointerId || start.y - e.clientY < SWIPE_UP) return;
224
+ swiped.current = true;
225
+ if (swipeTarget === 'right') setRightOpen(true);
226
+ else setLeftOpen(true);
227
+ }}
228
+ onPointerCancel={() => {
229
+ swipe.current = null;
230
+ }}
231
+ onClickCapture={(e) => {
232
+ if (!swiped.current) return;
233
+ swiped.current = false;
234
+ e.preventDefault();
235
+ e.stopPropagation();
236
+ }}
237
+ >
238
+ <span aria-hidden className='mx-auto h-1 w-9 shrink-0 rounded-full bg-border' />
239
+ {mobileUtilityBar}
240
+ </div>
241
+ </div>
242
+ )}
243
+
244
+ {!isDesktop && mobileUtilityBar && !sheets && (
180
245
  <div
181
246
  data-cb-workbench-utility-bar=''
182
247
  className={cn(
@@ -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