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.
- package/dist/react/canvas-hud/HudControls.d.ts +8 -2
- package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
- package/dist/react/canvas-hud/HudControls.js +6 -3
- package/dist/react/canvas-hud/HudControls.js.map +1 -1
- package/dist/react/components/AppShell.d.ts +14 -0
- package/dist/react/components/AppShell.d.ts.map +1 -1
- package/dist/react/components/AppShell.js +7 -31
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
- package/dist/react/components/ChipFilterStrip.js +2 -18
- package/dist/react/components/ChipFilterStrip.js.map +1 -1
- package/dist/react/components/FileDropZone.d.ts.map +1 -1
- package/dist/react/components/FileDropZone.js +4 -1
- package/dist/react/components/FileDropZone.js.map +1 -1
- package/dist/react/components/KanbanBoard.d.ts +55 -0
- package/dist/react/components/KanbanBoard.d.ts.map +1 -0
- package/dist/react/components/KanbanBoard.js +89 -0
- package/dist/react/components/KanbanBoard.js.map +1 -0
- package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
- package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
- package/dist/react/components/ResponsiveWorkbench.js +33 -4
- package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
- package/dist/react/components/SelectField.d.ts +18 -48
- package/dist/react/components/SelectField.d.ts.map +1 -1
- package/dist/react/components/SelectField.js +88 -11
- package/dist/react/components/SelectField.js.map +1 -1
- package/dist/react/components/SelectFieldImpl.d.ts +7 -0
- package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
- package/dist/react/components/SelectFieldImpl.js +26 -0
- package/dist/react/components/SelectFieldImpl.js.map +1 -0
- package/dist/react/components/filterChip.d.ts +8 -0
- package/dist/react/components/filterChip.d.ts.map +1 -0
- package/dist/react/components/filterChip.js +20 -0
- package/dist/react/components/filterChip.js.map +1 -0
- package/dist/react/hooks/pointerDnd.d.ts +37 -3
- package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
- package/dist/react/hooks/pointerDnd.js +101 -9
- package/dist/react/hooks/pointerDnd.js.map +1 -1
- package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
- package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
- package/dist/react/hooks/pragmaticDnd.js +9 -3
- package/dist/react/hooks/pragmaticDnd.js.map +1 -1
- package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
- package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
- package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
- package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/selectField.d.ts +72 -0
- package/dist/react/lib/selectField.d.ts.map +1 -0
- package/dist/react/lib/selectField.js +23 -0
- package/dist/react/lib/selectField.js.map +1 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +2 -1
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +2 -1
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/denseGallery.css +2 -0
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +6 -7
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
- package/dist/react/nav/CommandBar.d.ts.map +1 -1
- package/dist/react/nav/CommandBar.js +2 -1
- package/dist/react/nav/CommandBar.js.map +1 -1
- package/dist/react/palette/CommandPalette.d.ts +15 -1
- package/dist/react/palette/CommandPalette.d.ts.map +1 -1
- package/dist/react/palette/CommandPalette.js +58 -8
- package/dist/react/palette/CommandPalette.js.map +1 -1
- package/dist/react/palette/Kbd.d.ts +15 -0
- package/dist/react/palette/Kbd.d.ts.map +1 -0
- package/dist/react/palette/Kbd.js +17 -0
- package/dist/react/palette/Kbd.js.map +1 -0
- package/dist/react/palette/ShortcutSheet.d.ts +30 -0
- package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
- package/dist/react/palette/ShortcutSheet.js +15 -0
- package/dist/react/palette/ShortcutSheet.js.map +1 -0
- package/dist/react/palette/commandSources.d.ts +14 -0
- package/dist/react/palette/commandSources.d.ts.map +1 -1
- package/dist/react/palette/commandSources.js +11 -1
- package/dist/react/palette/commandSources.js.map +1 -1
- package/dist/react/palette/index.d.ts +4 -2
- package/dist/react/palette/index.d.ts.map +1 -1
- package/dist/react/palette/index.js +2 -0
- package/dist/react/palette/index.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/canvas-hud.css +2 -2
- package/dist/styles-areas/kanban.css +31 -0
- package/dist/styles-areas/palette.css +3 -3
- package/dist/styles-areas/workbench.css +15 -13
- package/dist/styles-utilities.css +42 -0
- package/package.json +4 -3
- package/scripts/guardrails-baseline.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +77 -6
- package/scripts/styleAreas.ts +2 -0
- package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
- package/src/react/canvas-hud/HudControls.tsx +33 -9
- package/src/react/components/AppShell.spec.tsx +40 -0
- package/src/react/components/AppShell.tsx +25 -27
- package/src/react/components/ChipFilterStrip.tsx +2 -38
- package/src/react/components/FileDropZone.spec.tsx +16 -0
- package/src/react/components/FileDropZone.tsx +3 -1
- package/src/react/components/KanbanBoard.spec.tsx +89 -0
- package/src/react/components/KanbanBoard.tsx +270 -0
- package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
- package/src/react/components/ResponsiveWorkbench.tsx +69 -4
- package/src/react/components/SelectField.spec.tsx +117 -0
- package/src/react/components/SelectField.tsx +131 -166
- package/src/react/components/SelectFieldImpl.tsx +140 -0
- package/src/react/components/filterChip.tsx +43 -0
- package/src/react/hooks/pointerDnd.spec.ts +89 -3
- package/src/react/hooks/pointerDnd.ts +103 -8
- package/src/react/hooks/pragmaticDnd.ts +13 -2
- package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
- package/src/react/index.ts +3 -0
- package/src/react/lib/floatingStyles.spec.ts +1 -1
- package/src/react/lib/selectField.ts +96 -0
- package/src/react/media/VideoChapterEditor.tsx +3 -2
- package/src/react/media/VideoCutEditor.tsx +3 -2
- package/src/react/media/denseGallery.css +2 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
- package/src/react/media-gallery/MediaGallery.tsx +16 -18
- package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
- package/src/react/nav/CommandBar.tsx +2 -3
- package/src/react/palette/CommandPalette.spec.tsx +73 -0
- package/src/react/palette/CommandPalette.tsx +153 -13
- package/src/react/palette/Kbd.tsx +46 -0
- package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
- package/src/react/palette/ShortcutSheet.tsx +110 -0
- package/src/react/palette/commandSources.tsx +30 -1
- package/src/react/palette/index.ts +9 -0
- package/src/styles-areas/canvas-hud.css +2 -2
- package/src/styles-areas/kanban.css +31 -0
- package/src/styles-areas/palette.css +3 -3
- package/src/styles-areas/workbench.css +15 -13
- 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
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
type SelectFieldImplComponent = ComponentType<SelectFieldImplProps>;
|
|
18
|
+
|
|
19
|
+
let loadedImpl: SelectFieldImplComponent | null = null;
|
|
20
|
+
let pendingImpl: Promise<SelectFieldImplComponent> | null = null;
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
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
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|