cursedbelt 5.52.2 → 5.54.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.
Files changed (106) hide show
  1. package/dist/react/activity-bar/ActivityBar.d.ts +39 -3
  2. package/dist/react/activity-bar/ActivityBar.d.ts.map +1 -1
  3. package/dist/react/activity-bar/ActivityBar.js +214 -6
  4. package/dist/react/activity-bar/ActivityBar.js.map +1 -1
  5. package/dist/react/activity-bar/activityBar.css +95 -10
  6. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
  7. package/dist/react/canvas-hud/HudControls.js +1 -1
  8. package/dist/react/canvas-hud/HudControls.js.map +1 -1
  9. package/dist/react/components/Button.d.ts +5 -4
  10. package/dist/react/components/Button.d.ts.map +1 -1
  11. package/dist/react/components/Button.js +3 -1
  12. package/dist/react/components/Button.js.map +1 -1
  13. package/dist/react/components/ListPanel.d.ts +40 -0
  14. package/dist/react/components/ListPanel.d.ts.map +1 -0
  15. package/dist/react/components/ListPanel.js +32 -0
  16. package/dist/react/components/ListPanel.js.map +1 -0
  17. package/dist/react/components/NativeSelect.d.ts.map +1 -1
  18. package/dist/react/components/NativeSelect.js +4 -2
  19. package/dist/react/components/NativeSelect.js.map +1 -1
  20. package/dist/react/components/SectionNav.d.ts +39 -0
  21. package/dist/react/components/SectionNav.d.ts.map +1 -0
  22. package/dist/react/components/SectionNav.js +76 -0
  23. package/dist/react/components/SectionNav.js.map +1 -0
  24. package/dist/react/components/Tooltip.d.ts +4 -2
  25. package/dist/react/components/Tooltip.d.ts.map +1 -1
  26. package/dist/react/components/Tooltip.js.map +1 -1
  27. package/dist/react/components/TooltipImpl.d.ts +0 -5
  28. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  29. package/dist/react/components/TooltipImpl.js +68 -0
  30. package/dist/react/components/TooltipImpl.js.map +1 -1
  31. package/dist/react/components/TreeView.js +14 -1
  32. package/dist/react/components/TreeView.js.map +1 -1
  33. package/dist/react/disk-usage/DiskUsageTree.d.ts +28 -3
  34. package/dist/react/disk-usage/DiskUsageTree.d.ts.map +1 -1
  35. package/dist/react/disk-usage/DiskUsageTree.js +93 -15
  36. package/dist/react/disk-usage/DiskUsageTree.js.map +1 -1
  37. package/dist/react/index.d.ts +2 -0
  38. package/dist/react/index.d.ts.map +1 -1
  39. package/dist/react/index.js +3 -0
  40. package/dist/react/index.js.map +1 -1
  41. package/dist/react/lib/cn.d.ts.map +1 -1
  42. package/dist/react/lib/cn.js +11 -1
  43. package/dist/react/lib/cn.js.map +1 -1
  44. package/dist/react/lib/infoHint.d.ts +6 -2
  45. package/dist/react/lib/infoHint.d.ts.map +1 -1
  46. package/dist/react/lib/infoHint.js +6 -2
  47. package/dist/react/lib/infoHint.js.map +1 -1
  48. package/dist/react/nav/PlaceBar.d.ts.map +1 -1
  49. package/dist/react/nav/PlaceBar.js +9 -4
  50. package/dist/react/nav/PlaceBar.js.map +1 -1
  51. package/dist/react/place-bar/index.d.ts +2 -0
  52. package/dist/react/place-bar/index.d.ts.map +1 -0
  53. package/dist/react/place-bar/index.js +9 -0
  54. package/dist/react/place-bar/index.js.map +1 -0
  55. package/dist/styles-areas/analytics.css +11 -11
  56. package/dist/styles-areas/calendar.css +14 -15
  57. package/dist/styles-areas/chart-table.css +2 -2
  58. package/dist/styles-areas/core.css +36 -34
  59. package/dist/styles-areas/dashboard-grid.css +3 -3
  60. package/dist/styles-areas/disk-usage.css +5 -4
  61. package/dist/styles-areas/fields.css +4 -3
  62. package/dist/styles-areas/layout-engine.css +10 -10
  63. package/dist/styles-areas/media.css +10 -10
  64. package/dist/styles-areas/nav.css +7 -7
  65. package/dist/styles-areas/place-bar.css +33 -0
  66. package/dist/styles-areas/workbench.css +3 -3
  67. package/dist/styles-utilities.css +40 -0
  68. package/package.json +21 -2
  69. package/scripts/styleAreas.ts +8 -0
  70. package/src/react/activity-bar/ActivityBar.spec.tsx +106 -1
  71. package/src/react/activity-bar/ActivityBar.tsx +221 -16
  72. package/src/react/activity-bar/activityBar.css +95 -10
  73. package/src/react/canvas-hud/HudControls.tsx +0 -1
  74. package/src/react/components/Button.tsx +9 -5
  75. package/src/react/components/InfoHint.spec.tsx +10 -0
  76. package/src/react/components/ListPanel.spec.tsx +73 -0
  77. package/src/react/components/ListPanel.tsx +97 -0
  78. package/src/react/components/NativeSelect.spec.tsx +10 -0
  79. package/src/react/components/NativeSelect.tsx +4 -2
  80. package/src/react/components/SectionNav.spec.tsx +98 -0
  81. package/src/react/components/SectionNav.tsx +143 -0
  82. package/src/react/components/Tooltip.tsx +4 -2
  83. package/src/react/components/TooltipImpl.tsx +74 -1
  84. package/src/react/components/TreeView.spec.tsx +28 -0
  85. package/src/react/components/TreeView.tsx +12 -1
  86. package/src/react/disk-usage/DiskUsage.spec.tsx +96 -0
  87. package/src/react/disk-usage/DiskUsageTree.tsx +193 -29
  88. package/src/react/index.ts +3 -0
  89. package/src/react/lib/cn.spec.ts +7 -0
  90. package/src/react/lib/cn.ts +11 -1
  91. package/src/react/lib/infoHint.ts +6 -2
  92. package/src/react/nav/PlaceBar.tsx +9 -5
  93. package/src/react/place-bar/index.ts +8 -0
  94. package/src/styles-areas/analytics.css +11 -11
  95. package/src/styles-areas/calendar.css +14 -15
  96. package/src/styles-areas/chart-table.css +2 -2
  97. package/src/styles-areas/core.css +36 -34
  98. package/src/styles-areas/dashboard-grid.css +3 -3
  99. package/src/styles-areas/disk-usage.css +5 -4
  100. package/src/styles-areas/fields.css +4 -3
  101. package/src/styles-areas/layout-engine.css +10 -10
  102. package/src/styles-areas/media.css +10 -10
  103. package/src/styles-areas/nav.css +7 -7
  104. package/src/styles-areas/place-bar.css +33 -0
  105. package/src/styles-areas/workbench.css +3 -3
  106. package/src/styles-utilities.css +40 -0
@@ -0,0 +1,143 @@
1
+ import { type KeyboardEvent, type ReactNode, useEffect, useRef } from 'react';
2
+ import { cn } from '../lib/cn.js';
3
+ import { Button } from './Button.js';
4
+ import { ScrollArea } from './ScrollArea.js';
5
+
6
+ export interface SectionNavItem {
7
+ id: string;
8
+ label: ReactNode;
9
+ /** Trailing mark, e.g. a dot for unsaved changes. */
10
+ marker?: ReactNode;
11
+ /** Words for the marker — the item's hint on hover and on a touch-and-hold, and part of its
12
+ * accessible name, so the mark is never colour alone. */
13
+ markerLabel?: string;
14
+ /** Refuses the item and says why (Button's `unavailableReason`): drawn disabled, still
15
+ * focusable and hoverable so the reason is reachable. */
16
+ unavailableReason?: string;
17
+ /** A trailing number (items in the section), right-aligned in tabular figures. */
18
+ count?: number;
19
+ }
20
+
21
+ export interface SectionNavProps {
22
+ items: readonly SectionNavItem[];
23
+ /** The `id` of the section shown beside the nav. */
24
+ value: string;
25
+ onValueChange: (id: string) => void;
26
+ 'aria-label': string;
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * The section selector of a settings page — the labelled list beside the form that Linear,
32
+ * GitHub and Vercel settings all draw. From `md` (768px) up it is a vertical column of
33
+ * full-width rows; below it, one line that scrolls sideways above the form, the active section
34
+ * kept in view.
35
+ *
36
+ * Each item is a ghost {@link Button}, so focus ring, `unavailableReason` and the coarse-pointer
37
+ * 44px floor come from the one primitive. Focus is roving: Tab enters on the active section and
38
+ * leaves the list; arrow keys (either axis) move among the items, Home/End jump to the ends.
39
+ * A refused item stays in that traversal on purpose — its reason is only reachable focused.
40
+ *
41
+ * `list-none m-0 p-0` and an explicit ground on every item: some hosts (desk) mount this with no
42
+ * Tailwind preflight, where a bare `<ul>` has bullets and a `<button>` the browser's grey box.
43
+ */
44
+ export function SectionNav({ items, value, onValueChange, 'aria-label': ariaLabel, className }: SectionNavProps) {
45
+ const refs = useRef(new Map<string, HTMLButtonElement>());
46
+ const previous = useRef(value);
47
+
48
+ // Keep the active section in view on the phone strip when it changes — not on mount, where
49
+ // `nearest` could scroll the page itself to a nav that is simply below the fold.
50
+ useEffect(() => {
51
+ if (previous.current === value) return;
52
+ previous.current = value;
53
+ const node = refs.current.get(value);
54
+ if (node && typeof node.scrollIntoView === 'function') node.scrollIntoView({ inline: 'nearest', block: 'nearest' });
55
+ }, [value]);
56
+
57
+ const tabStop = items.some((i) => i.id === value) ? value : items[0]?.id;
58
+
59
+ const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
60
+ const ids = items.map((i) => i.id);
61
+ const at = ids.findIndex((id) => refs.current.get(id) === document.activeElement);
62
+ if (at < 0 || ids.length === 0) return;
63
+ let next: number;
64
+ switch (e.key) {
65
+ case 'ArrowDown':
66
+ case 'ArrowRight':
67
+ next = (at + 1) % ids.length;
68
+ break;
69
+ case 'ArrowUp':
70
+ case 'ArrowLeft':
71
+ next = (at - 1 + ids.length) % ids.length;
72
+ break;
73
+ case 'Home':
74
+ next = 0;
75
+ break;
76
+ case 'End':
77
+ next = ids.length - 1;
78
+ break;
79
+ default:
80
+ return;
81
+ }
82
+ e.preventDefault();
83
+ refs.current.get(ids[next] as string)?.focus();
84
+ };
85
+
86
+ return (
87
+ <nav aria-label={ariaLabel} className={cn('min-w-0', className)}>
88
+ <ScrollArea
89
+ axis='x'
90
+ space='none'
91
+ safeArea={false}
92
+ className='flex-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:overflow-visible'
93
+ >
94
+ <ul
95
+ onKeyDown={onKeyDown}
96
+ className='m-0 flex w-max min-w-full list-none gap-1 whitespace-nowrap p-0 md:w-full md:flex-col md:gap-0.5'
97
+ >
98
+ {items.map((item) => {
99
+ const active = item.id === value;
100
+ return (
101
+ <li key={item.id} className='m-0 shrink-0 p-0 md:w-full'>
102
+ <Button
103
+ ref={(node) => {
104
+ if (node) refs.current.set(item.id, node);
105
+ else refs.current.delete(item.id);
106
+ }}
107
+ variant='ghost'
108
+ size='md'
109
+ tooltip={item.markerLabel ?? item.label ?? item.id}
110
+ tooltipSide='right'
111
+ tooltipMobile={item.markerLabel ? 'press' : undefined}
112
+ unavailableReason={item.unavailableReason}
113
+ aria-current={active ? 'page' : undefined}
114
+ tabIndex={item.id === tabStop ? 0 : -1}
115
+ onClick={() => {
116
+ if (!active) onValueChange(item.id);
117
+ }}
118
+ className={cn(
119
+ 'border-0 px-3 font-[inherit] md:w-full md:justify-start md:text-left',
120
+ active
121
+ ? 'bg-accent/10 font-medium text-foreground'
122
+ : 'bg-transparent font-normal text-muted-foreground hover:bg-muted hover:text-foreground',
123
+ )}
124
+ >
125
+ <span className='min-w-0 truncate'>{item.label}</span>
126
+ {item.marker != null && (
127
+ <span aria-hidden className='inline-flex shrink-0 items-center'>
128
+ {item.marker}
129
+ </span>
130
+ )}
131
+ {item.markerLabel && <span className='sr-only'>{`, ${item.markerLabel}`}</span>}
132
+ {item.count !== undefined && (
133
+ <span className='ml-auto pl-2 text-xs tabular-nums text-muted-foreground'>{item.count}</span>
134
+ )}
135
+ </Button>
136
+ </li>
137
+ );
138
+ })}
139
+ </ul>
140
+ </ScrollArea>
141
+ </nav>
142
+ );
143
+ }
@@ -16,7 +16,7 @@ export type TooltipPlacement = 'top' | 'right' | 'bottom' | 'left';
16
16
  /** Touch-device behavior (hover doesn't exist there). No effect on hover devices.
17
17
  * `icon` = a tappable ⓘ disclosure beside the trigger; `tap` = tap the trigger to
18
18
  * toggle; `off` = no tooltip on touch. */
19
- export type TooltipMobile = 'icon' | 'tap' | 'off';
19
+ export type TooltipMobile = 'icon' | 'tap' | 'press' | 'off';
20
20
 
21
21
  /** The gesture that arrived before the real tooltip existed, replayed onto the
22
22
  * trigger once Radix's handlers are attached. @see ./TooltipImpl */
@@ -40,7 +40,9 @@ export interface TooltipProps {
40
40
  /** Touch behavior. Default `'off'` (owner mobile teardown 2026-07-15): tooltips
41
41
  * are a HOVER refinement — on touch they must not exist as per-element ⓘ
42
42
  * clutter with sub-44px targets. Opt in to `'icon'`/`'tap'` only where the
43
- * bubble carries real information a touch user cannot get another way. */
43
+ * bubble carries real information a touch user cannot get another way.
44
+ * `'press'` adds nothing to the page: a touch-and-hold shows the bubble and a tap
45
+ * stays the trigger's own — the long-press label an icon-only control needs (CPY-4). */
44
46
  mobile?: TooltipMobile;
45
47
  /** Custom glyph for the `mobile='icon'` touch disclosure (default `i`). */
46
48
  mobileIcon?: ReactNode;
@@ -1,5 +1,5 @@
1
1
  import * as RTooltip from '@radix-ui/react-tooltip';
2
- import { cloneElement, isValidElement, useEffect, useRef, useState } from 'react';
2
+ import { cloneElement, isValidElement, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
3
3
  import { flushSync } from 'react-dom';
4
4
  import { useHasHover } from '../hooks/useHasHover.js';
5
5
  import { cn } from '../lib/cn.js';
@@ -28,6 +28,13 @@ interface TooltipImplProps extends TooltipProps {
28
28
  * shifts to stay on-screen, keyboard-reachable via focus. On touch devices (no
29
29
  * hover) it switches to the `mobile` affordance.
30
30
  */
31
+ /** How long a touch must hold before `mobile="press"` shows the label — Android's long-press. */
32
+ const PRESS_HOLD_MS = 500;
33
+ /** How far a holding finger may drift before it counts as a scroll instead. */
34
+ const PRESS_SLOP_PX = 10;
35
+ /** How long the label stays after the finger lifts, so it can be read. */
36
+ const PRESS_LINGER_MS = 1500;
37
+
31
38
  export default function TooltipImpl({
32
39
  children,
33
40
  content,
@@ -55,6 +62,12 @@ export default function TooltipImpl({
55
62
  const touchTriggerRef = useRef<HTMLButtonElement | null>(null);
56
63
  /** True while one of that trigger's own events is being dispatched — see `onTouchOpenChange`. */
57
64
  const inTriggerEventRef = useRef(false);
65
+ /** The `press` branch's hold in progress: where it started, its timer, and whether it held. */
66
+ const pressRef = useRef<{ x: number; y: number; held: boolean; timer?: ReturnType<typeof setTimeout> }>({
67
+ x: 0,
68
+ y: 0,
69
+ held: false,
70
+ });
58
71
 
59
72
  // Replay the arming gesture. Radix opens on `pointermove` over the trigger and on
60
73
  // focus; both already happened before this module existed, so re-dispatch one on
@@ -362,6 +375,66 @@ export default function TooltipImpl({
362
375
  onClick: markTriggerEvent,
363
376
  };
364
377
 
378
+ /*
379
+ * `press`: touch and HOLD shows the hint; a plain tap is the trigger's own and nothing else.
380
+ * The phone's label for an icon-only control (CPY-4) — how iOS and Android label a toolbar
381
+ * icon — with no ⓘ beside it and no change to what a tap does. The tap that ends a hold is
382
+ * swallowed, so reading a label never also presses the button. The handlers ride on the
383
+ * child itself (Radix's `asChild` Slot composes them), so no wrapper changes its layout.
384
+ */
385
+ if (mobile === 'press') {
386
+ const cancelPress = () => {
387
+ if (pressRef.current.timer !== undefined) clearTimeout(pressRef.current.timer);
388
+ pressRef.current.timer = undefined;
389
+ };
390
+ const pressProps = {
391
+ ...touchTriggerProps,
392
+ onPointerDown: (e: ReactPointerEvent) => {
393
+ markTriggerEvent();
394
+ if (e.pointerType === 'mouse') return;
395
+ cancelPress();
396
+ pressRef.current = { x: e.clientX, y: e.clientY, held: false };
397
+ pressRef.current.timer = setTimeout(() => {
398
+ pressRef.current.held = true;
399
+ pressRef.current.timer = undefined;
400
+ setOpen(true);
401
+ }, PRESS_HOLD_MS);
402
+ },
403
+ // A finger that travels is a scroll or a pan, never a request for the label.
404
+ onPointerMove: (e: ReactPointerEvent) => {
405
+ const { x, y } = pressRef.current;
406
+ if (Math.hypot(e.clientX - x, e.clientY - y) > PRESS_SLOP_PX) cancelPress();
407
+ },
408
+ onPointerUp: () => {
409
+ cancelPress();
410
+ if (pressRef.current.held) setTimeout(() => setOpen(false), PRESS_LINGER_MS);
411
+ },
412
+ onPointerCancel: cancelPress,
413
+ // Android answers a hold with `contextmenu` too; the label is the answer here.
414
+ onContextMenu: (e: ReactMouseEvent) => {
415
+ if (pressRef.current.held || pressRef.current.timer !== undefined) e.preventDefault();
416
+ },
417
+ onClickCapture: (e: ReactMouseEvent) => {
418
+ markTriggerEvent();
419
+ if (!pressRef.current.held) return;
420
+ pressRef.current.held = false;
421
+ e.preventDefault();
422
+ e.stopPropagation();
423
+ },
424
+ className: '[-webkit-touch-callout:none] select-none',
425
+ };
426
+ return (
427
+ <RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
428
+ <RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
429
+ <RTooltip.Trigger asChild {...pressProps}>
430
+ {children}
431
+ </RTooltip.Trigger>
432
+ {bubble}
433
+ </RTooltip.Root>
434
+ </RTooltip.Provider>
435
+ );
436
+ }
437
+
365
438
  // `icon`: render the trigger as-is plus a tappable ⓘ disclosure that owns the
366
439
  // tooltip (so a tap on the trigger still does its own action).
367
440
  if (mobile === 'icon') {
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, mock, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
+ import { useState } from 'react';
3
4
  import { type TreeNode, TreeView } from './TreeView.js';
4
5
 
5
6
  afterEach(cleanup);
@@ -161,6 +162,25 @@ describe('TreeView — selection', () => {
161
162
  expect(lastCall).toContain('b');
162
163
  });
163
164
 
165
+ test('🔴 a click on a NESTED row selects that row only — its parent row never hears it (forge task 2742)', () => {
166
+ // Child rows are DOM descendants of their parent's treeitem, so an unstopped click bubbled to the
167
+ // parent's handler too: in a controlled tree it toggled the parent against stale state and the
168
+ // ⌘-click that should have added a file emptied the selection (measured in station's Folder sizes).
169
+ function Controlled() {
170
+ const [sel, setSel] = useState<string[]>([]);
171
+ return <TreeView nodes={NODES} defaultExpanded={['a']} selectionMode='multi' selected={sel} onSelectionChange={setSel} />;
172
+ }
173
+ const { getByText } = render(<Controlled />);
174
+ const item = (label: string) => getByText(label).closest('[role="treeitem"]') as HTMLElement;
175
+ fireEvent.click(getByText('Alpha'));
176
+ fireEvent.click(getByText('Alpha 1'), { metaKey: true });
177
+ expect(item('Alpha').getAttribute('aria-selected')).toBe('true');
178
+ expect(item('Alpha 1').getAttribute('aria-selected')).toBe('true');
179
+ fireEvent.click(getByText('Alpha 1'));
180
+ expect(item('Alpha').getAttribute('aria-selected')).toBe('false');
181
+ expect(item('Alpha 1').getAttribute('aria-selected')).toBe('true');
182
+ });
183
+
164
184
  test('disabled node cannot be selected', () => {
165
185
  const onChange = mock((_ids: string[]) => {});
166
186
  const { getByText } = render(<TreeView nodes={NODES} onSelectionChange={onChange} />);
@@ -182,6 +202,14 @@ describe('TreeView — keyboard navigation', () => {
182
202
  expect(document.activeElement).toBe(items[1]);
183
203
  });
184
204
 
205
+ test('🔴 ArrowDown on a NESTED row moves to its next row, not its parent\'s (forge task 2742)', () => {
206
+ const { getByText } = render(<TreeView nodes={NODES} defaultExpanded={['a']} />);
207
+ const item = (label: string) => getByText(label).closest('[role="treeitem"]') as HTMLElement;
208
+ item('Alpha 1').focus();
209
+ fireEvent.keyDown(item('Alpha 1'), { key: 'ArrowDown' });
210
+ expect(document.activeElement).toBe(item('Alpha 2'));
211
+ });
212
+
185
213
  test('ArrowRight expands a collapsed node', () => {
186
214
  const { getAllByRole, getByText } = render(<TreeView nodes={NODES} />);
187
215
  const items = getAllByRole('treeitem');
@@ -431,6 +431,9 @@ function TreeItem({
431
431
 
432
432
  const handleClick = useCallback(
433
433
  (e: React.MouseEvent) => {
434
+ // 🔴 A child row is a DOM descendant of its parent's treeitem: unstopped, one click ran the
435
+ // parent's handler too, which toggled the parent against stale state (forge task 2742).
436
+ e.stopPropagation();
434
437
  if (node.disabled) return;
435
438
  setFocused(node.id);
436
439
  setSelected(node.id, { ctrl: e.metaKey || e.ctrlKey, shift: e.shiftKey });
@@ -443,6 +446,11 @@ function TreeItem({
443
446
  if (node.disabled) return;
444
447
  const ids = visibleIds;
445
448
  const idx = ids.indexOf(node.id);
449
+ // The keys this row answers stop here, so its ancestors' rows do not answer them again (a
450
+ // nested ArrowDown moved focus twice). Every other key — Delete, a shortcut — still bubbles.
451
+ if (['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(e.key)) {
452
+ e.stopPropagation();
453
+ }
446
454
 
447
455
  switch (e.key) {
448
456
  case 'ArrowDown': {
@@ -552,7 +560,10 @@ function TreeItem({
552
560
  tabIndex={tabIndex}
553
561
  onClick={handleClick}
554
562
  onKeyDown={handleKeyDown}
555
- onFocus={() => setFocused(node.id)}
563
+ // Only the row itself: focus bubbles too, and a nested row's focus named its parent focused.
564
+ onFocus={(e) => {
565
+ if (e.target === e.currentTarget) setFocused(node.id);
566
+ }}
556
567
  style={dragApi.style}
557
568
  // A disabled row reads in muted ink, not at half opacity: opacity-50 dragged its text
558
569
  // (and every descendant's) under 4.5:1 (FND-9, forge task 2594).
@@ -69,6 +69,7 @@ describe('DiskUsageTree', () => {
69
69
  }),
70
70
  node({ name: 'backups', path: '/data/backups', size: 900_000, childCount: 3 }),
71
71
  node({ name: 'skipped-dir', path: '/data/skipped-dir', size: 0, skipped: true }),
72
+ node({ name: 'Volumes', path: '/data/Volumes', size: 0, skipped: true, otherDevice: true }),
72
73
  ],
73
74
  });
74
75
  render(<DiskUsageTree root={root} loadChildren={mock(async () => null)} />);
@@ -76,10 +77,105 @@ describe('DiskUsageTree', () => {
76
77
  expect(screen.getByText('node_modules/')).toBeTruthy();
77
78
  expect(screen.getByLabelText('opaque')).toBeTruthy();
78
79
  expect(screen.getByText('not measured')).toBeTruthy();
80
+ expect(screen.getByText('another drive')).toBeTruthy();
79
81
  expect(screen.getByText(/12 smaller items/)).toBeTruthy();
80
82
  });
81
83
  });
82
84
 
85
+ describe('DiskUsageTree — Finder-style selection, Delete and the row menu (forge task 2742)', () => {
86
+ const three = () =>
87
+ node({
88
+ childCount: 3,
89
+ size: 600,
90
+ children: [
91
+ node({ name: 'a', path: '/data/a', kind: 'file', size: 300, childCount: 0 }),
92
+ node({ name: 'b', path: '/data/b', kind: 'file', size: 200, childCount: 0 }),
93
+ node({ name: 'c', path: '/data/c', kind: 'file', size: 100, childCount: 0 }),
94
+ ],
95
+ });
96
+ const row = (name: string) => screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
97
+
98
+ test('⌘-click toggles, ⇧-click takes the range, and the nodes come with the paths', () => {
99
+ const onSelectionChange = mock((_paths: string[], _nodes: TreeSliceNode[]) => {});
100
+ render(<DiskUsageTree root={three()} loadChildren={async () => null} selectable onSelectionChange={onSelectionChange} />);
101
+ fireEvent.click(row('a'));
102
+ fireEvent.click(row('c'), { shiftKey: true });
103
+ expect(onSelectionChange.mock.calls.at(-1)?.[0]).toEqual(['/data/a', '/data/b', '/data/c']);
104
+ fireEvent.click(row('b'), { metaKey: true });
105
+ const [paths, nodes] = onSelectionChange.mock.calls.at(-1) ?? [];
106
+ expect(paths).toEqual(['/data/a', '/data/c']);
107
+ expect(nodes?.map((n) => n.size)).toEqual([300, 100]);
108
+ });
109
+
110
+ test('Delete with a selection reports the selected nodes; with none it does nothing', () => {
111
+ const onDeleteKey = mock((_targets: TreeSliceNode[]) => {});
112
+ render(<DiskUsageTree root={three()} loadChildren={async () => null} selectable onDeleteKey={onDeleteKey} />);
113
+ fireEvent.keyDown(row('a'), { key: 'Backspace' });
114
+ expect(onDeleteKey).not.toHaveBeenCalled();
115
+ fireEvent.click(row('a'));
116
+ fireEvent.click(row('b'), { metaKey: true });
117
+ fireEvent.keyDown(row('b'), { key: 'Delete' });
118
+ expect(onDeleteKey.mock.calls[0]?.[0].map((n) => n.path)).toEqual(['/data/a', '/data/b']);
119
+ });
120
+
121
+ test('Delete on a row\'s ⋯ button — where focus lands when its menu closes — still deletes', () => {
122
+ const onDeleteKey = mock((_targets: TreeSliceNode[]) => {});
123
+ render(<DiskUsageTree root={three()} loadChildren={async () => null} selectable actions={() => []} onDeleteKey={onDeleteKey} />);
124
+ fireEvent.click(row('a'));
125
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Actions for a' }), { key: 'Backspace' });
126
+ expect(onDeleteKey.mock.calls[0]?.[0].map((n) => n.path)).toEqual(['/data/a']);
127
+ });
128
+
129
+ test('a right-click on an unselected row selects it alone and opens its menu on it', () => {
130
+ const actions = mock(({ targets }: { node: TreeSliceNode; targets: TreeSliceNode[] }) => [
131
+ { key: 'copy', label: `Copy ${targets.length}` },
132
+ ]);
133
+ const onSelectionChange = mock((_paths: string[]) => {});
134
+ render(
135
+ <DiskUsageTree root={three()} loadChildren={async () => null} selectable actions={actions} onSelectionChange={onSelectionChange} />,
136
+ );
137
+ fireEvent.click(row('a'));
138
+ fireEvent.click(row('b'), { metaKey: true });
139
+ // Inside the selection: the menu acts on both.
140
+ fireEvent.contextMenu(screen.getByText('b'));
141
+ expect(screen.getByText('Copy 2')).toBeTruthy();
142
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
143
+ // Outside it: c becomes the selection, and the menu acts on c alone.
144
+ fireEvent.contextMenu(screen.getByText('c'));
145
+ expect(onSelectionChange.mock.calls.at(-1)?.[0]).toEqual(['/data/c']);
146
+ expect(actions.mock.calls.at(-1)?.[0].targets.map((n) => n.path)).toEqual(['/data/c']);
147
+ });
148
+
149
+ test('a re-read tree opens where the reader left it, and reports what they open', () => {
150
+ const root = node({
151
+ childCount: 1,
152
+ size: 600,
153
+ children: [
154
+ node({
155
+ name: 'Movies',
156
+ path: '/data/Movies',
157
+ size: 600,
158
+ childCount: 1,
159
+ children: [node({ name: 'film.mov', path: '/data/Movies/film.mov', kind: 'file', size: 600, childCount: 0 })],
160
+ }),
161
+ ],
162
+ });
163
+ const onExpandedChange = mock((_paths: string[]) => {});
164
+ render(<DiskUsageTree root={root} loadChildren={async () => null} defaultExpanded={['/data/Movies']} onExpandedChange={onExpandedChange} />);
165
+ expect(screen.getByText('film.mov')).toBeTruthy();
166
+ fireEvent.keyDown(row('Movies/'), { key: 'ArrowLeft' });
167
+ expect(onExpandedChange.mock.calls.at(-1)?.[0]).toEqual([]);
168
+ });
169
+
170
+ test('every row carries a ⋯ button when there are actions, and none without', () => {
171
+ const { unmount } = render(<DiskUsageTree root={three()} loadChildren={async () => null} actions={() => []} />);
172
+ expect(screen.getAllByRole('button', { name: /^Actions for / })).toHaveLength(3);
173
+ unmount();
174
+ render(<DiskUsageTree root={three()} loadChildren={async () => null} />);
175
+ expect(screen.queryAllByRole('button', { name: /^Actions for / })).toHaveLength(0);
176
+ });
177
+ });
178
+
83
179
  describe('DiskUsageRulesEditor', () => {
84
180
  test('enables save only after an edit and reports the new rules', () => {
85
181
  const onSave = mock(() => {});