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
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
subscribePointerDragMonitor,
|
|
13
13
|
TOUCH_ACTIVATION_DELAY_MS,
|
|
14
14
|
TOUCH_ACTIVATION_TOLERANCE_PX,
|
|
15
|
+
registerTouchEdgeScroll,
|
|
16
|
+
TOUCH_EDGE_MAX_STEP_PX,
|
|
17
|
+
touchEdgeStep,
|
|
15
18
|
wireTouchDragSource,
|
|
16
19
|
withinTolerance,
|
|
17
20
|
} from './pointerDnd.js';
|
|
@@ -239,17 +242,41 @@ describe('createTouchDragSession', () => {
|
|
|
239
242
|
});
|
|
240
243
|
|
|
241
244
|
describe('wireTouchDragSource (DOM glue)', () => {
|
|
242
|
-
test('
|
|
245
|
+
test('leaves the element pannable: a move before the long press is a scroll, one after it is the drag', async () => {
|
|
243
246
|
const element = document.createElement('div');
|
|
244
247
|
element.style.touchAction = 'pan-y';
|
|
248
|
+
document.body.append(element);
|
|
245
249
|
const cleanup = wireTouchDragSource({
|
|
246
250
|
element,
|
|
247
251
|
getInitialData: () => ({}),
|
|
248
252
|
onDragStateChange: () => {},
|
|
253
|
+
delayMs: 5,
|
|
249
254
|
});
|
|
250
|
-
|
|
251
|
-
cleanup();
|
|
255
|
+
// The page keeps its own touch-action — `none` here made a phone board unscrollable from a card.
|
|
252
256
|
expect(element.style.touchAction).toBe('pan-y');
|
|
257
|
+
|
|
258
|
+
const down = new Event('pointerdown', { bubbles: true, cancelable: true });
|
|
259
|
+
Object.assign(down, { pointerType: 'touch', isPrimary: true, pointerId: 7, clientX: 10, clientY: 10 });
|
|
260
|
+
const touchMove = () => {
|
|
261
|
+
const e = new Event('touchmove', { bubbles: true, cancelable: true });
|
|
262
|
+
element.dispatchEvent(e);
|
|
263
|
+
return e.defaultPrevented;
|
|
264
|
+
};
|
|
265
|
+
const menu = () => {
|
|
266
|
+
const e = new Event('contextmenu', { bubbles: true, cancelable: true });
|
|
267
|
+
element.dispatchEvent(e);
|
|
268
|
+
return e.defaultPrevented;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
expect(menu()).toBe(false); // no press, no interference
|
|
272
|
+
element.dispatchEvent(down);
|
|
273
|
+
expect(touchMove()).toBe(false); // still waiting: the browser may scroll
|
|
274
|
+
expect(menu()).toBe(true); // the long press is ours, not Android's menu
|
|
275
|
+
await Bun.sleep(20);
|
|
276
|
+
expect(touchMove()).toBe(true); // the drag is live: the pan is cancelled
|
|
277
|
+
cleanup();
|
|
278
|
+
expect(touchMove()).toBe(false);
|
|
279
|
+
element.remove();
|
|
253
280
|
});
|
|
254
281
|
|
|
255
282
|
test('a long-press touch drag reaches the shared monitor and toggles the target', async () => {
|
|
@@ -309,3 +336,62 @@ describe('wireTouchDragSource (DOM glue)', () => {
|
|
|
309
336
|
target.remove();
|
|
310
337
|
});
|
|
311
338
|
});
|
|
339
|
+
|
|
340
|
+
describe('touch edge auto-scroll', () => {
|
|
341
|
+
const box = { left: 0, right: 400, top: 100, bottom: 700 };
|
|
342
|
+
|
|
343
|
+
test('nothing in the middle; faster the nearer the edge; full speed past it, while level with the box', () => {
|
|
344
|
+
expect(touchEdgeStep(box, 200, 400)).toEqual({ dx: 0, dy: 0 });
|
|
345
|
+
expect(touchEdgeStep(box, 399, 400).dx).toBe(TOUCH_EDGE_MAX_STEP_PX);
|
|
346
|
+
expect(touchEdgeStep(box, 376, 400).dx).toBeGreaterThan(0);
|
|
347
|
+
expect(touchEdgeStep(box, 376, 400).dx).toBeLessThan(TOUCH_EDGE_MAX_STEP_PX);
|
|
348
|
+
expect(touchEdgeStep(box, 2, 400).dx).toBeLessThan(0);
|
|
349
|
+
expect(touchEdgeStep(box, 200, 690).dy).toBeGreaterThan(0);
|
|
350
|
+
// Past the right side (a thumb at the screen edge, the scroller inset by a gutter): full speed.
|
|
351
|
+
expect(touchEdgeStep(box, 420, 400)).toEqual({ dx: TOUCH_EDGE_MAX_STEP_PX, dy: 0 });
|
|
352
|
+
// Above the box and past its side: level with neither, so it scrolls only toward the top.
|
|
353
|
+
expect(touchEdgeStep(box, 420, 50)).toEqual({ dx: 0, dy: 0 });
|
|
354
|
+
expect(touchEdgeStep(box, 200, 50)).toEqual({ dx: 0, dy: -TOUCH_EDGE_MAX_STEP_PX });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("a touch drag held at a scroller's edge scrolls it, snapping off until the drop", async () => {
|
|
358
|
+
const container = document.createElement('div');
|
|
359
|
+
container.style.scrollSnapType = 'x mandatory';
|
|
360
|
+
const card = document.createElement('div');
|
|
361
|
+
container.append(card);
|
|
362
|
+
document.body.append(container);
|
|
363
|
+
container.getBoundingClientRect = () => ({ ...box, x: 0, y: 100, width: 400, height: 600, toJSON: () => ({}) }) as DOMRect;
|
|
364
|
+
const scrolled: number[] = [];
|
|
365
|
+
container.scrollBy = ((x: number) => scrolled.push(x)) as typeof container.scrollBy;
|
|
366
|
+
const originalFromPoint = document.elementsFromPoint;
|
|
367
|
+
document.elementsFromPoint = () => [];
|
|
368
|
+
|
|
369
|
+
const off = registerTouchEdgeScroll(container);
|
|
370
|
+
const unwire = wireTouchDragSource({ element: card, getInitialData: () => ({}), onDragStateChange: () => {}, delayMs: 5 });
|
|
371
|
+
const pointer = (type: string, x: number, y: number) => {
|
|
372
|
+
const e = new Event(type, { bubbles: true, cancelable: true });
|
|
373
|
+
Object.assign(e, { pointerType: 'touch', isPrimary: true, pointerId: 3, clientX: x, clientY: y });
|
|
374
|
+
return e;
|
|
375
|
+
};
|
|
376
|
+
try {
|
|
377
|
+
card.dispatchEvent(pointer('pointerdown', 200, 400));
|
|
378
|
+
await Bun.sleep(20);
|
|
379
|
+
window.dispatchEvent(pointer('pointermove', 395, 400)); // carried to the right edge, then held
|
|
380
|
+
await Bun.sleep(120);
|
|
381
|
+
expect(scrolled.length).toBeGreaterThan(1);
|
|
382
|
+
expect(scrolled.every((dx) => dx > 0)).toBe(true);
|
|
383
|
+
expect(container.style.scrollSnapType).toBe('none');
|
|
384
|
+
window.dispatchEvent(pointer('pointerup', 395, 400));
|
|
385
|
+
const after = scrolled.length;
|
|
386
|
+
await Bun.sleep(60);
|
|
387
|
+
expect(scrolled.length).toBe(after); // the drop stops it
|
|
388
|
+
expect(container.style.scrollSnapType).toBe('x mandatory');
|
|
389
|
+
} finally {
|
|
390
|
+
unwire();
|
|
391
|
+
off();
|
|
392
|
+
document.elementsFromPoint = originalFromPoint;
|
|
393
|
+
container.remove();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
@@ -324,9 +324,15 @@ function elementsFromPointSafe(x: number, y: number): Element[] {
|
|
|
324
324
|
/**
|
|
325
325
|
* Wire touch-drag onto one draggable element, using the shared registry + monitor bus.
|
|
326
326
|
* Only `touch` pointers are handled — mouse/pen still drive pragmatic's native adapter,
|
|
327
|
-
* so the two engines never fight over the same gesture.
|
|
328
|
-
*
|
|
329
|
-
*
|
|
327
|
+
* so the two engines never fight over the same gesture.
|
|
328
|
+
*
|
|
329
|
+
* 🔴 The element stays PANNABLE. A finger that moves before the long press fires is a scroll,
|
|
330
|
+
* and the browser runs it; only once the drag is live does a non-passive `touchmove` on the
|
|
331
|
+
* element cancel the browser's pan, so the finger drives the drag instead — the way
|
|
332
|
+
* `@dnd-kit`'s TouchSensor does it. This used to set `touch-action: none` on the element, and
|
|
333
|
+
* on a phone a board whose columns are full of cards could not be swiped or scrolled from any
|
|
334
|
+
* card (forge task 3033, seen on vault-stage at 390). While a press is in flight the
|
|
335
|
+
* long-press `contextmenu` (Android's menu) is cancelled too.
|
|
330
336
|
*
|
|
331
337
|
* The `pointerdown` listener lives on the element; the `pointermove`/`up`/`cancel`/
|
|
332
338
|
* `keydown` listeners are only attached on the `window` for the life of one interaction
|
|
@@ -358,8 +364,6 @@ export function wireTouchDragSource(config: WireTouchDragConfig): CleanupFn {
|
|
|
358
364
|
|
|
359
365
|
const onPointerMove = (event: PointerEvent): void => {
|
|
360
366
|
if (event.pointerId !== activePointerId) return;
|
|
361
|
-
// Once dragging, suppress the browser's scroll so the finger drives the drag.
|
|
362
|
-
if (session.isActive() && event.cancelable) event.preventDefault();
|
|
363
367
|
session.pointerMove({ x: event.clientX, y: event.clientY });
|
|
364
368
|
if (session.status() === 'idle') detach(); // a pre-activation move was a scroll
|
|
365
369
|
};
|
|
@@ -406,14 +410,105 @@ export function wireTouchDragSource(config: WireTouchDragConfig): CleanupFn {
|
|
|
406
410
|
session.pointerDown({ x: event.clientX, y: event.clientY });
|
|
407
411
|
};
|
|
408
412
|
|
|
409
|
-
|
|
410
|
-
element.
|
|
413
|
+
// Touch events go to the element the finger came down on for the whole gesture, so the
|
|
414
|
+
// element's own listener sees every move. Non-passive: it must be able to cancel the pan.
|
|
415
|
+
const onTouchMove = (event: TouchEvent): void => {
|
|
416
|
+
if (session.isActive() && event.cancelable) event.preventDefault();
|
|
417
|
+
};
|
|
418
|
+
const onContextMenu = (event: Event): void => {
|
|
419
|
+
if (session.status() !== 'idle') event.preventDefault();
|
|
420
|
+
};
|
|
411
421
|
element.addEventListener('pointerdown', onPointerDown);
|
|
422
|
+
element.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
423
|
+
element.addEventListener('contextmenu', onContextMenu);
|
|
412
424
|
|
|
413
425
|
return () => {
|
|
414
426
|
session.cancel();
|
|
415
427
|
detach();
|
|
416
|
-
element.style.touchAction = prevTouchAction;
|
|
417
428
|
element.removeEventListener('pointerdown', onPointerDown);
|
|
429
|
+
element.removeEventListener('touchmove', onTouchMove);
|
|
430
|
+
element.removeEventListener('contextmenu', onContextMenu);
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- Edge auto-scroll for touch drags -------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/** How near a scroller's edge (px) a touch drag must come before it scrolls, and its top speed. */
|
|
437
|
+
export const TOUCH_EDGE_PX = 48;
|
|
438
|
+
export const TOUCH_EDGE_MAX_STEP_PX = 14;
|
|
439
|
+
|
|
440
|
+
interface EdgeRect {
|
|
441
|
+
left: number;
|
|
442
|
+
right: number;
|
|
443
|
+
top: number;
|
|
444
|
+
bottom: number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* How far one frame scrolls a scroller whose box is `rect` while a finger drags at (x, y):
|
|
449
|
+
* nothing in the middle, faster the nearer the edge, full speed at or PAST it — a thumb at the
|
|
450
|
+
* screen's edge is often outside a scroller inset by the page's gutter (vault-stage at 390: the
|
|
451
|
+
* strip ends at 374, the thumb sat at 385). An axis only moves while the finger is level with the
|
|
452
|
+
* box on the other one. Pure, so the feel is tested without a layout.
|
|
453
|
+
*/
|
|
454
|
+
export function touchEdgeStep(rect: EdgeRect, x: number, y: number): { dx: number; dy: number } {
|
|
455
|
+
const ramp = (into: number) => Math.round(TOUCH_EDGE_MAX_STEP_PX * Math.min(1, 1 - into / TOUCH_EDGE_PX));
|
|
456
|
+
let dx = 0;
|
|
457
|
+
let dy = 0;
|
|
458
|
+
if (y >= rect.top && y <= rect.bottom) {
|
|
459
|
+
if (x < rect.left + TOUCH_EDGE_PX) dx = -ramp(x - rect.left);
|
|
460
|
+
else if (x > rect.right - TOUCH_EDGE_PX) dx = ramp(rect.right - x);
|
|
461
|
+
}
|
|
462
|
+
if (x >= rect.left && x <= rect.right) {
|
|
463
|
+
if (y < rect.top + TOUCH_EDGE_PX) dy = -ramp(y - rect.top);
|
|
464
|
+
else if (y > rect.bottom - TOUCH_EDGE_PX) dy = ramp(rect.bottom - y);
|
|
465
|
+
}
|
|
466
|
+
return { dx: dx || 0, dy: dy || 0 };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Scroll `container` while a TOUCH drag holds near its edge — what pragmatic's
|
|
471
|
+
* `autoScrollForElements` does for a mouse, which the touch bridge never reaches. Without it a
|
|
472
|
+
* phone board whose columns are each a screen wide could not carry a card to any other column
|
|
473
|
+
* (forge task 3033). A finger held still keeps scrolling (one step a frame); scroll snapping is
|
|
474
|
+
* suspended while it does, and the container settles back onto its snap points on the drop.
|
|
475
|
+
*/
|
|
476
|
+
export function registerTouchEdgeScroll(container: HTMLElement): CleanupFn {
|
|
477
|
+
let point: PointerPoint | null = null;
|
|
478
|
+
let frame = 0;
|
|
479
|
+
let snapWas: string | null = null;
|
|
480
|
+
const tick = (): void => {
|
|
481
|
+
frame = 0;
|
|
482
|
+
if (!point) return;
|
|
483
|
+
const { dx, dy } = touchEdgeStep(container.getBoundingClientRect(), point.x, point.y);
|
|
484
|
+
if (dx !== 0 || dy !== 0) {
|
|
485
|
+
if (snapWas === null) {
|
|
486
|
+
snapWas = container.style.scrollSnapType;
|
|
487
|
+
container.style.scrollSnapType = 'none';
|
|
488
|
+
}
|
|
489
|
+
container.scrollBy(dx, dy);
|
|
490
|
+
}
|
|
491
|
+
frame = requestAnimationFrame(tick);
|
|
492
|
+
};
|
|
493
|
+
const stop = (): void => {
|
|
494
|
+
point = null;
|
|
495
|
+
if (frame) cancelAnimationFrame(frame);
|
|
496
|
+
frame = 0;
|
|
497
|
+
if (snapWas !== null) {
|
|
498
|
+
container.style.scrollSnapType = snapWas;
|
|
499
|
+
snapWas = null;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const unsubscribe = subscribePointerDragMonitor((event) => {
|
|
503
|
+
if (event.phase === 'drop') {
|
|
504
|
+
stop();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
point = { x: event.current.input.clientX, y: event.current.input.clientY };
|
|
508
|
+
if (!frame) frame = requestAnimationFrame(tick);
|
|
509
|
+
});
|
|
510
|
+
return () => {
|
|
511
|
+
unsubscribe();
|
|
512
|
+
stop();
|
|
418
513
|
};
|
|
419
514
|
}
|
|
@@ -23,6 +23,7 @@ import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hi
|
|
|
23
23
|
import { useEffect, useRef, useState } from 'react';
|
|
24
24
|
import {
|
|
25
25
|
registerPointerDropTarget,
|
|
26
|
+
registerTouchEdgeScroll,
|
|
26
27
|
subscribePointerDragMonitor,
|
|
27
28
|
wireTouchDragSource,
|
|
28
29
|
} from './pointerDnd.js';
|
|
@@ -105,11 +106,21 @@ export function findScrollContainer(start: Element | null): Element | null {
|
|
|
105
106
|
// auto-scroll exactly ONCE (double-registering would compound the scroll velocity).
|
|
106
107
|
const autoScrollRegistry = new Map<Element, { count: number; cleanup: CleanupFn }>();
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Register `element` as an auto-scroll container for the drag engine (ref-counted) — for a
|
|
111
|
+
* mouse (pragmatic's own) and for a touch drag (`registerTouchEdgeScroll`, forge task 3033).
|
|
112
|
+
*/
|
|
109
113
|
export function registerAutoScroll(element: Element): CleanupFn {
|
|
110
114
|
const existing = autoScrollRegistry.get(element);
|
|
111
115
|
if (existing) existing.count += 1;
|
|
112
|
-
else
|
|
116
|
+
else
|
|
117
|
+
autoScrollRegistry.set(element, {
|
|
118
|
+
count: 1,
|
|
119
|
+
cleanup: combine(
|
|
120
|
+
autoScrollForElements({ element }),
|
|
121
|
+
element instanceof HTMLElement ? registerTouchEdgeScroll(element) : () => {},
|
|
122
|
+
),
|
|
123
|
+
});
|
|
113
124
|
let released = false;
|
|
114
125
|
return () => {
|
|
115
126
|
if (released) return;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Publishes the bottom chrome's height (dock + `PlaceBar` + safe area) as
|
|
5
|
+
* `--cb-bottom-chrome-h` on the root, so what floats over the page — the `Toaster`'s stack
|
|
6
|
+
* (OVL-9) — rises above the navigation instead of covering it. A `PlaceBar` hidden at `md` up
|
|
7
|
+
* measures 0, so the desktop stack drops back to the corner by itself.
|
|
8
|
+
*
|
|
9
|
+
* `AppShell` uses it for its dock and `PlaceBar`; an app that draws its own bottom bar without
|
|
10
|
+
* the shell (the vault's phone `PlaceBar`, forge task 3033) puts the returned ref on the bar's
|
|
11
|
+
* wrapper. Its own module so that app does not pull the shell into its entry chunk.
|
|
12
|
+
*/
|
|
13
|
+
export function usePublishedBottomChrome(shown: boolean) {
|
|
14
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
15
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `shown` mounts or drops the element the ref points at.
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
const el = ref.current;
|
|
18
|
+
const root = document.documentElement;
|
|
19
|
+
if (!el) return;
|
|
20
|
+
const publish = () => root.style.setProperty('--cb-bottom-chrome-h', `${el.offsetHeight}px`);
|
|
21
|
+
publish();
|
|
22
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(publish);
|
|
23
|
+
observer?.observe(el);
|
|
24
|
+
return () => {
|
|
25
|
+
observer?.disconnect();
|
|
26
|
+
root.style.removeProperty('--cb-bottom-chrome-h');
|
|
27
|
+
};
|
|
28
|
+
}, [shown]);
|
|
29
|
+
return ref;
|
|
30
|
+
}
|
|
@@ -110,17 +110,19 @@ describe('useScrollMemory', () => {
|
|
|
110
110
|
*/
|
|
111
111
|
test('a list growing page by page is followed past the deadline, as long as each page lands within it', async () => {
|
|
112
112
|
const s = fakeScroller(20_000);
|
|
113
|
-
const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={
|
|
113
|
+
const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={150} />);
|
|
114
114
|
s.scrollTo(15_000);
|
|
115
115
|
const list = { state: window.history.state, path: '/' };
|
|
116
116
|
push('/item/7');
|
|
117
|
-
rerender(<Shell at='/item/7' el={s.el} deadlineMs={
|
|
117
|
+
rerender(<Shell at='/item/7' el={s.el} deadlineMs={150} />);
|
|
118
118
|
|
|
119
119
|
s.grow(3_000);
|
|
120
120
|
back(list);
|
|
121
|
-
rerender(<Shell at='/' el={s.el} deadlineMs={
|
|
122
|
-
//
|
|
123
|
-
|
|
121
|
+
rerender(<Shell at='/' el={s.el} deadlineMs={150} />);
|
|
122
|
+
// Eight pages, 40ms apart: 320ms in all — over twice the 150ms deadline from the start, and
|
|
123
|
+
// never near 150ms without a page. (A 60ms deadline left a 40ms sleep 1.5x headroom, and it
|
|
124
|
+
// went red under a busy machine's full gate, 2026-10-13.)
|
|
125
|
+
for (const height of [5_000, 7_000, 9_000, 11_000, 13_000, 15_000, 17_000, 20_000]) {
|
|
124
126
|
await act(() => Bun.sleep(40));
|
|
125
127
|
s.grow(height);
|
|
126
128
|
}
|
package/src/react/index.ts
CHANGED
|
@@ -127,6 +127,7 @@ export {
|
|
|
127
127
|
// state, task 2275) must not have to import the media gallery — and its optional spreadsheet
|
|
128
128
|
// peer — to hear a `RailSection` toggle.
|
|
129
129
|
export { onPreferenceSeed, type PreferenceSeedListener } from './media-gallery/preferenceBus.js';
|
|
130
|
+
export { useRememberedPreference } from './media-gallery/rememberedPreference.js';
|
|
130
131
|
export { onPreferenceWrite, type PreferenceListener, seedPreferences } from './media-gallery/preferenceSync.js';
|
|
131
132
|
export {
|
|
132
133
|
type ScrollMemoryOptions,
|
|
@@ -139,6 +140,7 @@ export {
|
|
|
139
140
|
type AmbientIntensity,
|
|
140
141
|
} from './components/AmbientBackdrop.js';
|
|
141
142
|
export { AppShell, type AppShellProps } from './components/AppShell.js';
|
|
143
|
+
export { usePublishedBottomChrome } from './hooks/usePublishedBottomChrome.js';
|
|
142
144
|
export { BannerLane, type BannerLaneProps, type ShellBanner } from './components/BannerLane.js';
|
|
143
145
|
export { AuthGate, type AuthGateProps, notifyUnauthorized } from './AuthGate.js';
|
|
144
146
|
export { AutocompleteField, type AutocompleteProps } from './components/Autocomplete.js';
|
|
@@ -155,6 +157,7 @@ export {
|
|
|
155
157
|
} from './components/ChartTablePanel.js';
|
|
156
158
|
export * from './companion-link/index.js';
|
|
157
159
|
export { CardTapTarget, type CardTapTargetProps } from './components/CardTapTarget.js';
|
|
160
|
+
export { KanbanBoard, type KanbanBoardProps, type KanbanColumn } from './components/KanbanBoard.js';
|
|
158
161
|
export { EntityCard, type EntityCardProps } from './components/EntityCard.js';
|
|
159
162
|
export { CardDisplayControls, type CardDisplayControlsProps } from './components/CardDisplayControls.js';
|
|
160
163
|
export {
|
|
@@ -27,7 +27,7 @@ describe('the floating layer — DESIGN.md §5 containment', () => {
|
|
|
27
27
|
'components/Popover.tsx',
|
|
28
28
|
'components/DropdownMenu.tsx',
|
|
29
29
|
'components/HoverCard.tsx',
|
|
30
|
-
'components/
|
|
30
|
+
'components/SelectFieldImpl.tsx',
|
|
31
31
|
'components/ComboboxField.tsx',
|
|
32
32
|
'components/Autocomplete.tsx',
|
|
33
33
|
'components/TooltipImpl.tsx',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from './cn.js';
|
|
3
|
+
import { type ControlSize, fieldControlClass } from './field.js';
|
|
4
|
+
|
|
5
|
+
/** Shared by `components/SelectField.tsx` (the light trigger) and `components/SelectFieldImpl.tsx`
|
|
6
|
+
* (the lazily loaded Radix select) — here so the two draw the same box and neither widens the
|
|
7
|
+
* public surface. */
|
|
8
|
+
|
|
9
|
+
export interface SelectOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SelectGroup {
|
|
17
|
+
label?: string;
|
|
18
|
+
options: SelectOption[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SelectFieldProps {
|
|
22
|
+
value?: string;
|
|
23
|
+
onValueChange?: (value: string) => void;
|
|
24
|
+
options?: SelectOption[];
|
|
25
|
+
groups?: SelectGroup[];
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
/** Use the full field style (16px, rounded-md, border, focus ring). Default true. */
|
|
29
|
+
fieldStyle?: boolean;
|
|
30
|
+
/** Trigger height on the shared control-height scale (`--control-h-*`, task
|
|
31
|
+
* cbux-41). Default `md`. No-op when `fieldStyle={false}`. */
|
|
32
|
+
size?: ControlSize;
|
|
33
|
+
/** Forwarded to the trigger so a visible `<label htmlFor>` can target it. */
|
|
34
|
+
id?: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
required?: boolean;
|
|
37
|
+
className?: string;
|
|
38
|
+
'aria-label'?: string;
|
|
39
|
+
'aria-labelledby'?: string;
|
|
40
|
+
/**
|
|
41
|
+
* What to say when there is nothing to choose — *"No lists yet"*.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 Required in practice for any control whose options come from DATA rather than
|
|
44
|
+
* from a constant: an empty popup is a dead end that reads as a broken control, and
|
|
45
|
+
* the `choice-needs-empty-state` guardrail reports one. Rendered as plain text, not
|
|
46
|
+
* as an item, so it can never be picked or keyboard-focused.
|
|
47
|
+
*/
|
|
48
|
+
emptyLabel?: ReactNode;
|
|
49
|
+
/** The second line — what belongs in this list, or how to get one. */
|
|
50
|
+
emptyHint?: ReactNode;
|
|
51
|
+
/** Set by {@link Field} for inline validation — paints the field-token invalid
|
|
52
|
+
* border (via `aria-invalid`) and links the description/error to the trigger. */
|
|
53
|
+
'aria-invalid'?: boolean | 'true' | 'false';
|
|
54
|
+
'aria-describedby'?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the light trigger hands the real select when it replaces it. Internal. */
|
|
58
|
+
export interface SelectFieldImplProps extends SelectFieldProps {
|
|
59
|
+
/** The trigger was pressed (or Enter/Space/an arrow) before the implementation arrived. */
|
|
60
|
+
initialOpen?: boolean;
|
|
61
|
+
/** The trigger had keyboard focus: keep it on the real one. */
|
|
62
|
+
initialFocus?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The keys Radix Select opens on from its trigger (`OPEN_KEYS` in `@radix-ui/react-select`). */
|
|
66
|
+
export const SELECT_OPEN_KEYS: readonly string[] = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
|
|
67
|
+
|
|
68
|
+
/** The trigger's classes — one function, so the light trigger and Radix's are the same box and the
|
|
69
|
+
* swap between them moves nothing. */
|
|
70
|
+
export function selectTriggerClass({
|
|
71
|
+
fieldStyle = true,
|
|
72
|
+
size = 'md',
|
|
73
|
+
className,
|
|
74
|
+
}: Pick<SelectFieldProps, 'fieldStyle' | 'size' | 'className'>): string {
|
|
75
|
+
return cn(
|
|
76
|
+
fieldStyle && fieldControlClass(size),
|
|
77
|
+
'flex items-center justify-between gap-2 data-[placeholder]:text-muted-foreground',
|
|
78
|
+
className,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The value's text wrapper. One line, ellipsized, left-aligned: a long value (a deep path) used to
|
|
83
|
+
* wrap to three centred lines and spill out of a 36px trigger at 390px (Radix Themes' Select
|
|
84
|
+
* truncates the same way, forge task 2594). Radix's Value drops `className`, so the truncation
|
|
85
|
+
* lives on a wrapper. */
|
|
86
|
+
export const SELECT_VALUE_CLASS = 'block min-w-0 flex-1 truncate text-left';
|
|
87
|
+
|
|
88
|
+
/** `groups` when given, else `options` as one unlabelled group. */
|
|
89
|
+
export function selectGroupsOf({ options = [], groups = [] }: Pick<SelectFieldProps, 'options' | 'groups'>): SelectGroup[] {
|
|
90
|
+
return groups.length > 0 ? groups : options.length > 0 ? [{ options }] : [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Radix Select shows the placeholder for exactly these values (`shouldShowPlaceholder`). */
|
|
94
|
+
export function selectShowsPlaceholder(value: string | undefined): boolean {
|
|
95
|
+
return value === undefined || value === '';
|
|
96
|
+
}
|