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.
- 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/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 +3 -2
- 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/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
|
@@ -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
|
|
|
@@ -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
|
+
}
|