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
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { PRESS } from '../lib/press.js';
|
|
1
|
+
import { cn } from '../lib/cn.js';
|
|
2
|
+
import { Chip } from './filterChip.js';
|
|
4
3
|
import { ScrollArea } from './ScrollArea.js';
|
|
5
4
|
|
|
6
5
|
export interface ChipOption {
|
|
@@ -104,38 +103,3 @@ export function ChipFilterStrip({
|
|
|
104
103
|
</fieldset>
|
|
105
104
|
);
|
|
106
105
|
}
|
|
107
|
-
|
|
108
|
-
interface ChipProps {
|
|
109
|
-
active: boolean;
|
|
110
|
-
onClick: () => void;
|
|
111
|
-
children: ReactNode;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function Chip({ active, onClick, children }: ChipProps) {
|
|
115
|
-
return (
|
|
116
|
-
<button
|
|
117
|
-
// guardrails-ignore no-raw-action-button: presentational filter-chip toggle —
|
|
118
|
-
// reflects local filter state (aria-pressed), no destructive/mutating intent.
|
|
119
|
-
type='button'
|
|
120
|
-
onClick={onClick}
|
|
121
|
-
aria-pressed={active}
|
|
122
|
-
className={cn(
|
|
123
|
-
RAW_BUTTON,
|
|
124
|
-
// 44 px min tap target height via py + min-h (STANDARDS §4a). px-4 keeps every
|
|
125
|
-
// pill's internal padding identical regardless of label length.
|
|
126
|
-
'inline-flex min-h-11 shrink-0 cursor-pointer items-center rounded-full px-4 text-sm font-medium',
|
|
127
|
-
PRESS,
|
|
128
|
-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
129
|
-
// Soft active state — an accent tint + accent text (the standardized active
|
|
130
|
-
// treatment shared with nav items and the `accent` Badge tone), not a stark
|
|
131
|
-
// solid accent block. `text-accent-strong` (not plain `text-accent`) — the
|
|
132
|
-
// /15 tint only clears 4.43:1 with the base accent hue, under AA (cbux-101).
|
|
133
|
-
active
|
|
134
|
-
? 'bg-accent/15 text-accent-strong ring-1 ring-inset ring-accent/30'
|
|
135
|
-
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
136
|
-
)}
|
|
137
|
-
>
|
|
138
|
-
{children}
|
|
139
|
-
</button>
|
|
140
|
-
);
|
|
141
|
-
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { KanbanBoard } from './KanbanBoard.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => cleanup());
|
|
6
|
+
|
|
7
|
+
interface Card {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
}
|
|
11
|
+
const COLUMNS = [
|
|
12
|
+
{ id: 'todo', title: 'To do', items: [{ id: 'a', title: 'Spec the API' }, { id: 'b', title: 'Pick colors' }] },
|
|
13
|
+
{ id: 'done', title: 'Done', items: [] as Card[] },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function board(over: Partial<Parameters<typeof KanbanBoard<Card>>[0]> = {}) {
|
|
17
|
+
const opened: string[] = [];
|
|
18
|
+
const added: string[] = [];
|
|
19
|
+
render(
|
|
20
|
+
<KanbanBoard<Card>
|
|
21
|
+
aria-label='Work'
|
|
22
|
+
columns={COLUMNS}
|
|
23
|
+
getKey={(c) => c.id}
|
|
24
|
+
cardLabel={(c) => c.title}
|
|
25
|
+
renderCard={(c) => <span>{c.title}</span>}
|
|
26
|
+
onOpen={(c) => opened.push(c.id)}
|
|
27
|
+
onAdd={(id) => added.push(id)}
|
|
28
|
+
addLabel='Add task'
|
|
29
|
+
{...over}
|
|
30
|
+
/>,
|
|
31
|
+
);
|
|
32
|
+
return { opened, added };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('KanbanBoard', () => {
|
|
36
|
+
it('draws each column with its name and count, and says so for an empty one', () => {
|
|
37
|
+
board();
|
|
38
|
+
expect(screen.getByRole('region', { name: 'To do, 2 cards' })).toBeTruthy();
|
|
39
|
+
expect(screen.getByRole('region', { name: 'Done, 0 cards' })).toBeTruthy();
|
|
40
|
+
expect(screen.getByText('Nothing here yet')).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('a card is the tap target: click and Enter open it', () => {
|
|
44
|
+
const { opened } = board();
|
|
45
|
+
fireEvent.click(screen.getByRole('button', { name: 'Spec the API' }));
|
|
46
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Pick colors' }), { key: 'Enter' });
|
|
47
|
+
expect(opened).toEqual(['a', 'b']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("the column's + starts a card IN that column, and names the column", () => {
|
|
51
|
+
const { added } = board();
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add task to Done' }));
|
|
53
|
+
expect(added).toEqual(['done']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('marks the open card, and offers New column only when asked', () => {
|
|
57
|
+
board({ selectedKey: 'b' });
|
|
58
|
+
expect(document.querySelector('[data-cb-kanban-card="b"]')?.getAttribute('data-selected')).toBe('true');
|
|
59
|
+
expect(document.querySelector('[data-cb-kanban-card="a"]')?.hasAttribute('data-selected')).toBe(false);
|
|
60
|
+
expect(screen.queryByRole('button', { name: /New column/ })).toBeNull();
|
|
61
|
+
cleanup();
|
|
62
|
+
let made = 0;
|
|
63
|
+
board({ onNewColumn: () => made++ });
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: /New column/ }));
|
|
65
|
+
expect(made).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a phone's tray names every column with its count; a pill glides to its column and lights", () => {
|
|
69
|
+
const glided: Element[] = [];
|
|
70
|
+
const original = Element.prototype.scrollIntoView;
|
|
71
|
+
Element.prototype.scrollIntoView = function (this: Element) {
|
|
72
|
+
glided.push(this);
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
board();
|
|
76
|
+
const tray = screen.getByRole('navigation', { name: 'Work: columns' });
|
|
77
|
+
const done = Array.from(tray.querySelectorAll('button')).find((b) => b.textContent === 'Done0');
|
|
78
|
+
expect(Array.from(tray.querySelectorAll('button')).map((b) => [b.textContent, b.getAttribute('aria-pressed')])).toEqual([
|
|
79
|
+
['To do2', 'true'],
|
|
80
|
+
['Done0', 'false'],
|
|
81
|
+
]);
|
|
82
|
+
fireEvent.click(done as HTMLButtonElement);
|
|
83
|
+
expect(glided.map((el) => el.getAttribute('data-cb-kanban-column'))).toEqual(['done']);
|
|
84
|
+
expect(done?.getAttribute('aria-pressed')).toBe('true');
|
|
85
|
+
} finally {
|
|
86
|
+
Element.prototype.scrollIntoView = original;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { Plus } from 'lucide-react';
|
|
2
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { registerAutoScroll } from '../hooks/pragmaticDnd.js';
|
|
4
|
+
import { useCrossContainerDrag } from '../hooks/useCrossContainerDrag.js';
|
|
5
|
+
import { cn } from '../lib/cn.js';
|
|
6
|
+
import { Badge } from './Badge.js';
|
|
7
|
+
import { Button } from './Button.js';
|
|
8
|
+
import { CardTapTarget } from './CardTapTarget.js';
|
|
9
|
+
import { Chip } from './filterChip.js';
|
|
10
|
+
import { IconButton } from './IconButton.js';
|
|
11
|
+
import { ScrollArea } from './ScrollArea.js';
|
|
12
|
+
|
|
13
|
+
/** One column of a {@link KanbanBoard}: what it is called, and the cards in it, in order. */
|
|
14
|
+
export interface KanbanColumn<T> {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
items: readonly T[];
|
|
18
|
+
/** A glyph before the title. */
|
|
19
|
+
icon?: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface KanbanBoardProps<T> {
|
|
23
|
+
columns: readonly KanbanColumn<T>[];
|
|
24
|
+
/** A stable key per card, unique across every column. */
|
|
25
|
+
getKey: (item: T) => string;
|
|
26
|
+
/** What is ON the card — a glyph, a title, one detail line. The board draws the card around it. */
|
|
27
|
+
renderCard: (item: T) => ReactNode;
|
|
28
|
+
/** The card's accessible name, and the text of the ghost that follows a drag. */
|
|
29
|
+
cardLabel: (item: T) => string;
|
|
30
|
+
/** A card was clicked, tapped, or Enter'd. */
|
|
31
|
+
onOpen: (item: T) => void;
|
|
32
|
+
/** The card that is open — drawn as the active place (NAV-6). */
|
|
33
|
+
selectedKey?: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* A card was dropped in ANOTHER column. Omit it and the board is read-only: nothing drags.
|
|
36
|
+
* Order within a column is the consumer's (the board never reorders), so a drop back into
|
|
37
|
+
* its own column is not a move.
|
|
38
|
+
*/
|
|
39
|
+
onMove?: (itemKey: string, fromColumn: string, toColumn: string) => void;
|
|
40
|
+
/** The column's `+`: start a new card in it. */
|
|
41
|
+
onAdd?: (columnId: string) => void;
|
|
42
|
+
/** The `+`'s verb — "Add entry", "Add task". */
|
|
43
|
+
addLabel?: string;
|
|
44
|
+
/** A trailing "New column" button after the last column. */
|
|
45
|
+
onNewColumn?: () => void;
|
|
46
|
+
newColumnLabel?: string;
|
|
47
|
+
/** What an empty column says. */
|
|
48
|
+
emptyText?: string;
|
|
49
|
+
'aria-label': string;
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cards in columns, dragged between them — the board Linear and Trello settle on (forge task
|
|
55
|
+
* 3030, the vault's Kanban canvas). The columns scroll sideways as a strip and each column
|
|
56
|
+
* scrolls on its own; a card is the whole tap target (`CardTapTarget`) and drags through
|
|
57
|
+
* {@link useCrossContainerDrag} (pointer, touch, the edge indicator, auto-scroll).
|
|
58
|
+
*
|
|
59
|
+
* Under 768px a phone sees ONE column at a time: the strip snaps column by column, and the
|
|
60
|
+
* column titles become a pill tray above it — tap one to glide there, and the one in view is
|
|
61
|
+
* lit (forge task 3033, the redesign's "Pill Segmenter"). Under 1024px the strip snaps too.
|
|
62
|
+
*
|
|
63
|
+
* 🔴 Dragging is a PLUS. Every move a drag makes must also be reachable from the card's own
|
|
64
|
+
* detail (a folder or status picker): the hook's keyboard lift does not yet carry a card across
|
|
65
|
+
* columns, so the board never relies on it.
|
|
66
|
+
*/
|
|
67
|
+
export function KanbanBoard<T>({
|
|
68
|
+
columns,
|
|
69
|
+
getKey,
|
|
70
|
+
renderCard,
|
|
71
|
+
cardLabel,
|
|
72
|
+
onOpen,
|
|
73
|
+
selectedKey = null,
|
|
74
|
+
onMove,
|
|
75
|
+
onAdd,
|
|
76
|
+
addLabel = 'Add',
|
|
77
|
+
onNewColumn,
|
|
78
|
+
newColumnLabel = 'New column',
|
|
79
|
+
emptyText = 'Nothing here yet',
|
|
80
|
+
'aria-label': ariaLabel,
|
|
81
|
+
className,
|
|
82
|
+
}: KanbanBoardProps<T>) {
|
|
83
|
+
const containers = useMemo(() => columns.map((c) => ({ id: c.id, items: [...c.items] })), [columns]);
|
|
84
|
+
const onDrop = useCallback(
|
|
85
|
+
(from: string, to: string, fromIndex: number) => {
|
|
86
|
+
if (from === to || !onMove) return;
|
|
87
|
+
const item = containers.find((c) => c.id === from)?.items[fromIndex];
|
|
88
|
+
if (item !== undefined) onMove(getKey(item), from, to);
|
|
89
|
+
},
|
|
90
|
+
[containers, getKey, onMove],
|
|
91
|
+
);
|
|
92
|
+
const { ContainerContext, Container, Item } = useCrossContainerDrag<T>({
|
|
93
|
+
containers,
|
|
94
|
+
getKey,
|
|
95
|
+
onMove: onDrop,
|
|
96
|
+
disabled: !onMove,
|
|
97
|
+
renderOverlay: (item) => (
|
|
98
|
+
<div className='max-w-64 truncate rounded-md bg-surface px-3 py-2 text-sm font-medium text-foreground shadow-2xl ring-1 ring-accent'>
|
|
99
|
+
{cardLabel(item)}
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The phone's tray follows the column in view. Columns are the strip's full width there, so
|
|
105
|
+
// at most one is 60 % visible; wider, the tray is not drawn and this is unused.
|
|
106
|
+
const stripRef = useRef<HTMLDivElement | null>(null);
|
|
107
|
+
const columnEls = useRef(new Map<string, HTMLElement>());
|
|
108
|
+
const [inView, setInView] = useState<string | null>(null);
|
|
109
|
+
const columnIds = columns.map((c) => c.id).join('\n');
|
|
110
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `columnIds` is the trigger — a column added or gone re-observes the set held in the ref.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const root = stripRef.current;
|
|
113
|
+
if (!root || typeof IntersectionObserver === 'undefined') return;
|
|
114
|
+
const io = new IntersectionObserver(
|
|
115
|
+
(records) => {
|
|
116
|
+
for (const r of records) if (r.isIntersecting) setInView((r.target as HTMLElement).dataset.cbKanbanColumn ?? null);
|
|
117
|
+
},
|
|
118
|
+
{ root, threshold: 0.6 },
|
|
119
|
+
);
|
|
120
|
+
for (const el of columnEls.current.values()) io.observe(el);
|
|
121
|
+
return () => io.disconnect();
|
|
122
|
+
}, [columnIds]);
|
|
123
|
+
// A card carried to the strip's edge scrolls the strip, for a mouse and a finger: the hook
|
|
124
|
+
// only registers each card's NEAREST scroller, which is its column.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const strip = stripRef.current;
|
|
127
|
+
return strip && onMove ? registerAutoScroll(strip) : undefined;
|
|
128
|
+
}, [onMove]);
|
|
129
|
+
const current = columns.some((c) => c.id === inView) ? inView : (columns[0]?.id ?? null);
|
|
130
|
+
const showColumn = (id: string) => {
|
|
131
|
+
columnEls.current.get(id)?.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
|
132
|
+
setInView(id);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<ContainerContext>
|
|
137
|
+
<div className={cn('flex min-h-0 min-w-0 flex-1 flex-col gap-2', className)} data-cb-kanban-frame>
|
|
138
|
+
<nav aria-label={`${ariaLabel}: columns`} data-cb-kanban-tray className='min-w-0 md:hidden'>
|
|
139
|
+
<ScrollArea
|
|
140
|
+
axis='x'
|
|
141
|
+
space='none'
|
|
142
|
+
safeArea={false}
|
|
143
|
+
className='flex items-center gap-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
|
144
|
+
>
|
|
145
|
+
{columns.map((column) => (
|
|
146
|
+
<Chip key={column.id} active={column.id === current} onClick={() => showColumn(column.id)}>
|
|
147
|
+
{column.title}
|
|
148
|
+
<span className='ml-1 text-xs tabular-nums'>{column.items.length}</span>
|
|
149
|
+
</Chip>
|
|
150
|
+
))}
|
|
151
|
+
</ScrollArea>
|
|
152
|
+
</nav>
|
|
153
|
+
<ScrollArea
|
|
154
|
+
axis='x'
|
|
155
|
+
fade
|
|
156
|
+
space='compact'
|
|
157
|
+
safeArea={false}
|
|
158
|
+
role='group'
|
|
159
|
+
aria-label={ariaLabel}
|
|
160
|
+
data-cb-kanban
|
|
161
|
+
scrollRef={stripRef}
|
|
162
|
+
className='min-h-0 flex-1 max-lg:snap-x max-lg:snap-mandatory'
|
|
163
|
+
>
|
|
164
|
+
<div className='flex h-full items-start gap-3'>
|
|
165
|
+
{columns.map((column) => (
|
|
166
|
+
<Container key={column.id} containerId={column.id}>
|
|
167
|
+
{({ setNodeRef, isOver, items }) => (
|
|
168
|
+
<section
|
|
169
|
+
ref={(node) => {
|
|
170
|
+
setNodeRef(node);
|
|
171
|
+
if (node) columnEls.current.set(column.id, node);
|
|
172
|
+
else columnEls.current.delete(column.id);
|
|
173
|
+
}}
|
|
174
|
+
aria-label={`${column.title}, ${items.length} ${items.length === 1 ? 'card' : 'cards'}`}
|
|
175
|
+
data-cb-kanban-column={column.id}
|
|
176
|
+
className={cn(
|
|
177
|
+
'flex max-h-full w-72 shrink-0 snap-start flex-col gap-2 rounded-lg bg-muted/40 p-2 transition-colors max-md:w-full',
|
|
178
|
+
isOver && 'bg-muted ring-1 ring-inset ring-accent/50',
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
<header className='flex min-h-8 items-center gap-2 px-1'>
|
|
182
|
+
{column.icon && (
|
|
183
|
+
<span aria-hidden className='flex shrink-0 text-muted-foreground [&_svg]:size-4'>
|
|
184
|
+
{column.icon}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
<h3 className='m-0 min-w-0 truncate text-sm font-medium text-foreground'>{column.title}</h3>
|
|
188
|
+
<Badge tone='neutral' className='tabular-nums'>
|
|
189
|
+
{items.length}
|
|
190
|
+
</Badge>
|
|
191
|
+
{onAdd && (
|
|
192
|
+
<IconButton
|
|
193
|
+
size='sm'
|
|
194
|
+
className='ml-auto'
|
|
195
|
+
label={`${addLabel} to ${column.title}`}
|
|
196
|
+
onClick={() => onAdd(column.id)}
|
|
197
|
+
>
|
|
198
|
+
<Plus />
|
|
199
|
+
</IconButton>
|
|
200
|
+
)}
|
|
201
|
+
</header>
|
|
202
|
+
{/* `overscroll-x-auto`: a y-scroller is a scroll container on BOTH axes, and the
|
|
203
|
+
ScrollArea's `overscroll-contain` kept every sideways swipe and wheel that
|
|
204
|
+
started on a card from reaching the strip (vault-stage at 390, 3033). */}
|
|
205
|
+
<ScrollArea
|
|
206
|
+
axis='y'
|
|
207
|
+
space='compact'
|
|
208
|
+
safeArea={false}
|
|
209
|
+
fade
|
|
210
|
+
className='flex flex-col gap-2 overscroll-x-auto'
|
|
211
|
+
>
|
|
212
|
+
{items.length === 0 ? (
|
|
213
|
+
<p className='m-0 rounded-md border border-dashed border-border px-3 py-6 text-center text-caption text-muted-foreground'>
|
|
214
|
+
{emptyText}
|
|
215
|
+
</p>
|
|
216
|
+
) : (
|
|
217
|
+
items.map((item) => {
|
|
218
|
+
const key = getKey(item);
|
|
219
|
+
const selected = key === selectedKey;
|
|
220
|
+
return (
|
|
221
|
+
<Item key={key} containerId={column.id} itemKey={key}>
|
|
222
|
+
{({ setNodeRef: cardRef, isDragging }) => (
|
|
223
|
+
<div
|
|
224
|
+
ref={cardRef}
|
|
225
|
+
data-cb-kanban-card={key}
|
|
226
|
+
data-selected={selected || undefined}
|
|
227
|
+
className={cn('relative isolate', isDragging && 'opacity-40')}
|
|
228
|
+
>
|
|
229
|
+
<CardTapTarget
|
|
230
|
+
aria-label={cardLabel(item)}
|
|
231
|
+
onClick={() => onOpen(item)}
|
|
232
|
+
className={cn(
|
|
233
|
+
// A border, not a ring: the column scrolls, and a scroller clips the
|
|
234
|
+
// ring a card draws OUTSIDE itself (seen on vault-stage, 2026-10-13).
|
|
235
|
+
'flex w-full flex-col gap-1 rounded-md border border-border bg-surface px-3 py-2 text-sm transition-colors hover:border-border-strong focus-visible:border-accent focus-visible:outline-none',
|
|
236
|
+
onMove && 'cursor-grab active:cursor-grabbing',
|
|
237
|
+
selected && 'border-accent/70',
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
{renderCard(item)}
|
|
241
|
+
</CardTapTarget>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
</Item>
|
|
245
|
+
);
|
|
246
|
+
})
|
|
247
|
+
)}
|
|
248
|
+
</ScrollArea>
|
|
249
|
+
</section>
|
|
250
|
+
)}
|
|
251
|
+
</Container>
|
|
252
|
+
))}
|
|
253
|
+
{onNewColumn && (
|
|
254
|
+
<Button
|
|
255
|
+
variant='ghost'
|
|
256
|
+
size='sm'
|
|
257
|
+
className='shrink-0'
|
|
258
|
+
leftIcon={<Plus />}
|
|
259
|
+
tooltip={`${newColumnLabel} — a new column at the end`}
|
|
260
|
+
onClick={onNewColumn}
|
|
261
|
+
>
|
|
262
|
+
{newColumnLabel}
|
|
263
|
+
</Button>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</ScrollArea>
|
|
267
|
+
</div>
|
|
268
|
+
</ContainerContext>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -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(
|