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
|
@@ -60,10 +60,16 @@ export interface HudZoomControlsProps {
|
|
|
60
60
|
/** The canvas's own utilities, drawn before the zoom keys in the same pill (CNV-4 — utilities
|
|
61
61
|
* only; preferences and primary actions belong in the page's panel). */
|
|
62
62
|
children?: ReactNode;
|
|
63
|
+
/**
|
|
64
|
+
* `vertical` stacks the keys down an edge — +, the zoom %, −, Fit, Center — the way Figma and
|
|
65
|
+
* Miro draw them on a phone, where a bottom-centre pill sits over the drawing and the thumb's
|
|
66
|
+
* sheet handle. Place it with `absolute right-3 top-1/2 -translate-y-1/2` or the like.
|
|
67
|
+
*/
|
|
68
|
+
orientation?: 'horizontal' | 'vertical';
|
|
63
69
|
className?: string;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
/** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center. */
|
|
72
|
+
/** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center (a column: + on top). */
|
|
67
73
|
export function HudZoomControls({
|
|
68
74
|
scale,
|
|
69
75
|
onZoomIn,
|
|
@@ -73,20 +79,38 @@ export function HudZoomControls({
|
|
|
73
79
|
fitLabel = 'Fit to screen',
|
|
74
80
|
centerLabel = 'Center on the selection',
|
|
75
81
|
children,
|
|
82
|
+
orientation = 'horizontal',
|
|
76
83
|
className,
|
|
77
84
|
}: HudZoomControlsProps) {
|
|
85
|
+
const vertical = orientation === 'vertical';
|
|
86
|
+
const zoomOut = (
|
|
87
|
+
<HudButton label='Zoom out' onClick={onZoomOut}>
|
|
88
|
+
<IconZoomOut />
|
|
89
|
+
</HudButton>
|
|
90
|
+
);
|
|
91
|
+
const zoomIn = (
|
|
92
|
+
<HudButton label='Zoom in' onClick={onZoomIn}>
|
|
93
|
+
<IconZoomIn />
|
|
94
|
+
</HudButton>
|
|
95
|
+
);
|
|
78
96
|
return (
|
|
79
|
-
<HudPill
|
|
97
|
+
<HudPill
|
|
98
|
+
role='group'
|
|
99
|
+
aria-label='View'
|
|
100
|
+
aria-orientation={orientation}
|
|
101
|
+
data-orientation={orientation}
|
|
102
|
+
className={cn('gap-0.5 rounded-lg px-1 py-1', vertical && 'flex-col py-1.5', className)}
|
|
103
|
+
>
|
|
80
104
|
{children}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
{/* Up the column is in: + on top, as on every map. */}
|
|
106
|
+
{vertical ? zoomIn : zoomOut}
|
|
107
|
+
<span
|
|
108
|
+
className={cn('text-center text-xs text-muted-foreground tabular-nums', vertical ? 'w-full' : 'min-w-11')}
|
|
109
|
+
data-testid='hud-zoom'
|
|
110
|
+
>
|
|
85
111
|
{Math.round(scale * 100)}%
|
|
86
112
|
</span>
|
|
87
|
-
|
|
88
|
-
<IconZoomIn />
|
|
89
|
-
</HudButton>
|
|
113
|
+
{vertical ? zoomOut : zoomIn}
|
|
90
114
|
<HudButton label={fitLabel} onClick={onFit}>
|
|
91
115
|
<IconFit />
|
|
92
116
|
</HudButton>
|
|
@@ -302,6 +302,46 @@ describe('AppShell', () => {
|
|
|
302
302
|
expect(q(account as HTMLElement, '[data-testid="avatar"]')).not.toBeNull();
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
test('headerCenter: a one-place app’s toolbar takes the center zone, in place of the command spine', () => {
|
|
306
|
+
const { container } = render(
|
|
307
|
+
<AppShell navHeader={<h1>App</h1>} headerCenter={<div data-testid='toolbar'>tools</div>}>
|
|
308
|
+
x
|
|
309
|
+
</AppShell>,
|
|
310
|
+
);
|
|
311
|
+
const center = q(container, '[data-cb-header-zone="center"]') as HTMLElement;
|
|
312
|
+
expect(q(center, '[data-testid="toolbar"]')).not.toBeNull();
|
|
313
|
+
expect(q(container, '[data-cb-commandbar]')).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('headerCenter is ignored when nav gives the header places to draw', () => {
|
|
317
|
+
const { container } = render(
|
|
318
|
+
<AppShell nav={nav} onNavigate={() => {}} headerCenter={<div data-testid='toolbar'>tools</div>}>
|
|
319
|
+
x
|
|
320
|
+
</AppShell>,
|
|
321
|
+
);
|
|
322
|
+
expect(q(container, '[data-testid="toolbar"]')).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('immersive: the header and the bottom chrome step aside, a banner stays, the content stays', () => {
|
|
326
|
+
const { container, rerender } = render(
|
|
327
|
+
<AppShell nav={nav} onNavigate={() => {}} account={<span>A</span>} banners={[{ id: 'b', tone: 'error', message: 'Down' }]}>
|
|
328
|
+
page content
|
|
329
|
+
</AppShell>,
|
|
330
|
+
);
|
|
331
|
+
expect(q(container, '[data-cb-appheader]')).not.toBeNull();
|
|
332
|
+
expect(q(container, '[data-cb-placebar]')).not.toBeNull();
|
|
333
|
+
rerender(
|
|
334
|
+
<AppShell immersive nav={nav} onNavigate={() => {}} account={<span>A</span>} banners={[{ id: 'b', tone: 'error', message: 'Down' }]}>
|
|
335
|
+
page content
|
|
336
|
+
</AppShell>,
|
|
337
|
+
);
|
|
338
|
+
expect(q(container, '[data-cb-appshell]')?.hasAttribute('data-cb-immersive')).toBe(true);
|
|
339
|
+
expect(q(container, '[data-cb-appheader]')).toBeNull();
|
|
340
|
+
expect(q(container, '[data-cb-placebar]')).toBeNull();
|
|
341
|
+
expect(container.textContent).toContain('Down');
|
|
342
|
+
expect(container.textContent).toContain('page content');
|
|
343
|
+
});
|
|
344
|
+
|
|
305
345
|
test('the active place carries aria-current=page in every rail variant and the PlaceBar', () => {
|
|
306
346
|
const { container } = render(
|
|
307
347
|
<AppShell nav={station} activeHref='/console' onNavigate={() => {}}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ElementType, type ReactNode, type Ref, useEffect,
|
|
1
|
+
import { type ElementType, type ReactNode, type Ref, useEffect, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
allowAllGate,
|
|
4
4
|
deriveSideNavDomains,
|
|
@@ -13,6 +13,7 @@ import { whenIdle } from '../lib/whenIdle.js';
|
|
|
13
13
|
import { CommandBar } from '../nav/CommandBar.js';
|
|
14
14
|
import { type IconResolver, isDomainActive, type NavBadgeTone, renderIcon, routeClick } from '../nav/navShared.js';
|
|
15
15
|
import { PlaceBar } from '../nav/PlaceBar.js';
|
|
16
|
+
import { usePublishedBottomChrome } from '../hooks/usePublishedBottomChrome.js';
|
|
16
17
|
import { SideNav } from '../nav/SideNav.js';
|
|
17
18
|
import { TopBarPlaces } from '../nav/TopBar.js';
|
|
18
19
|
import { CommandSourceProvider, useCommandSources } from '../palette/commandSources.js';
|
|
@@ -137,6 +138,22 @@ export interface AppShellProps {
|
|
|
137
138
|
/** App slot at the trailing edge of the command spine (e.g. presence dots). */
|
|
138
139
|
commandBarExtra?: ReactNode;
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The header's center zone, for an app with no places and no command spine: a one-place
|
|
143
|
+
* canvas app's own toolbar (its depth switch, Save, Export) folded INTO the header instead of
|
|
144
|
+
* stacked under it as a second bar. On a phone that row is a quarter of what the canvas gets
|
|
145
|
+
* (arch, forge task 3059). Ignored when `nav` gives the header places to draw; otherwise it
|
|
146
|
+
* REPLACES the command spine (`onOpenCommand`, `onCapture`, `commandBarExtra`) — put them in it.
|
|
147
|
+
*/
|
|
148
|
+
headerCenter?: ReactNode;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Presentation mode: the header and the bottom chrome (dock, `PlaceBar`) step aside so the
|
|
152
|
+
* content has the whole viewport, until the app turns it off — a canvas's double-tap on blank
|
|
153
|
+
* space (Figma's and Miro's ⌘\). Banners stay: one is a fault the reader must still see.
|
|
154
|
+
*/
|
|
155
|
+
immersive?: boolean;
|
|
156
|
+
|
|
140
157
|
/** Account chrome behind the avatar (profile/settings/sign-out) — NOT a nav door
|
|
141
158
|
* (§6). Rendered at the trailing edge of the top spine. */
|
|
142
159
|
account?: ReactNode;
|
|
@@ -262,6 +279,8 @@ function Shell({
|
|
|
262
279
|
commandLabel,
|
|
263
280
|
commandHint,
|
|
264
281
|
commandBarExtra,
|
|
282
|
+
headerCenter,
|
|
283
|
+
immersive = false,
|
|
265
284
|
account,
|
|
266
285
|
bottomDock,
|
|
267
286
|
palette,
|
|
@@ -302,8 +321,8 @@ function Shell({
|
|
|
302
321
|
else window.location.assign(path);
|
|
303
322
|
};
|
|
304
323
|
|
|
305
|
-
const showSpine = Boolean(nav || navHeader || account || openCommand || onCapture || commandBarExtra);
|
|
306
|
-
const showBottom = Boolean(bottomDock || nav);
|
|
324
|
+
const showSpine = !immersive && Boolean(nav || navHeader || account || openCommand || onCapture || commandBarExtra || headerCenter);
|
|
325
|
+
const showBottom = !immersive && Boolean(bottomDock || nav);
|
|
307
326
|
const bottomChromeRef = usePublishedBottomChrome(showBottom);
|
|
308
327
|
// Chrome that lets the field through. The rails/spine keep their own directional
|
|
309
328
|
// border either way — only the FILL changes, so the layout is byte-identical and
|
|
@@ -344,6 +363,7 @@ function Shell({
|
|
|
344
363
|
<div
|
|
345
364
|
data-cb-appshell=''
|
|
346
365
|
data-cb-nav-pattern={pattern}
|
|
366
|
+
data-cb-immersive={immersive ? '' : undefined}
|
|
347
367
|
className={cn(
|
|
348
368
|
'flex h-dvh flex-col overflow-hidden bg-background text-foreground',
|
|
349
369
|
// `isolation: isolate` is load-bearing, not decoration: the blobs blend with
|
|
@@ -397,6 +417,8 @@ function Shell({
|
|
|
397
417
|
linkProps: navLinkComponent ? undefined : { onClick: routeClick(d.path, onNavigate) },
|
|
398
418
|
}))}
|
|
399
419
|
/>
|
|
420
|
+
) : headerCenter ? (
|
|
421
|
+
headerCenter
|
|
400
422
|
) : (
|
|
401
423
|
commandBar
|
|
402
424
|
)}
|
|
@@ -507,27 +529,3 @@ function Shell({
|
|
|
507
529
|
);
|
|
508
530
|
}
|
|
509
531
|
|
|
510
|
-
/**
|
|
511
|
-
* Publishes the bottom chrome's height (dock + `PlaceBar` + safe area) as
|
|
512
|
-
* `--cb-bottom-chrome-h` on the root, so what floats over the page — the `Toaster`'s stack
|
|
513
|
-
* (OVL-9) — rises above the navigation instead of covering it. A `PlaceBar` hidden at `md` up
|
|
514
|
-
* measures 0, so the desktop stack drops back to the corner by itself.
|
|
515
|
-
*/
|
|
516
|
-
function usePublishedBottomChrome(shown: boolean) {
|
|
517
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
518
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `shown` mounts or drops the element the ref points at.
|
|
519
|
-
useLayoutEffect(() => {
|
|
520
|
-
const el = ref.current;
|
|
521
|
-
const root = document.documentElement;
|
|
522
|
-
if (!el) return;
|
|
523
|
-
const publish = () => root.style.setProperty('--cb-bottom-chrome-h', `${el.offsetHeight}px`);
|
|
524
|
-
publish();
|
|
525
|
-
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(publish);
|
|
526
|
-
observer?.observe(el);
|
|
527
|
-
return () => {
|
|
528
|
-
observer?.disconnect();
|
|
529
|
-
root.style.removeProperty('--cb-bottom-chrome-h');
|
|
530
|
-
};
|
|
531
|
-
}, [shown]);
|
|
532
|
-
return ref;
|
|
533
|
-
}
|
|
@@ -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
|
-
}
|
|
@@ -162,6 +162,22 @@ describe('FileDropZone', () => {
|
|
|
162
162
|
expect(input.className).toContain('peer');
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* 🔴 Transparent in EVERY state, disabled included (2026-10-13, vault-stage at 390 and 1440): the
|
|
167
|
+
* input is cursedbelt's `Input`, whose field classes end `disabled:opacity-50` — a variant, so it
|
|
168
|
+
* outranks the zone's `opacity-0` exactly when the zone is disabled, and the browser's own
|
|
169
|
+
* "Choose Files · No file chosen" was painted over the hint saying why the zone is off.
|
|
170
|
+
*/
|
|
171
|
+
test('a DISABLED zone keeps its input transparent — no opacity class but opacity-0, in any state', () => {
|
|
172
|
+
for (const disabled of [false, true]) {
|
|
173
|
+
const { container, unmount } = render(<FileDropZone disabled={disabled} onFiles={() => {}} />);
|
|
174
|
+
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
175
|
+
const opacities = input.className.split(/\s+/).filter((c) => /(^|:)opacity-/.test(c));
|
|
176
|
+
expect({ disabled, opacities: opacities.filter((c) => !/(^|:)opacity-0$/.test(c)) }).toEqual({ disabled, opacities: [] });
|
|
177
|
+
unmount();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
165
181
|
/**
|
|
166
182
|
* 🔴 The 2026-09-28 roms report: the owner unpacked a `.7z` in Finder and dropped
|
|
167
183
|
* the FOLDER — `Loaded (USA) (En,Fr)/` holding a readme and, one level down, a
|
|
@@ -342,7 +342,9 @@ export function FileDropZone({
|
|
|
342
342
|
accept={accept}
|
|
343
343
|
multiple={multiple}
|
|
344
344
|
disabled={disabled}
|
|
345
|
-
|
|
345
|
+
// `disabled:opacity-0` too: the field classes' `disabled:opacity-50` is a variant, and would
|
|
346
|
+
// paint the browser's "Choose Files" over a disabled zone's hint (FileDropZone.spec.tsx).
|
|
347
|
+
className='peer pointer-events-none absolute inset-0 h-full w-full opacity-0 disabled:opacity-0'
|
|
346
348
|
tabIndex={browse === 'both' ? -1 : undefined}
|
|
347
349
|
{...(browse === 'folder' ? DIRECTORY_PICKER : {})}
|
|
348
350
|
onChange={(e) => {
|
|
@@ -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
|
+
}
|