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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.52.2",
3
+ "version": "5.54.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -384,6 +384,12 @@
384
384
  "source": "./src/react/pdf-viewer/index.ts",
385
385
  "import": "./dist/react/pdf-viewer/index.js"
386
386
  },
387
+ "./react/place-bar": {
388
+ "types": "./dist/react/place-bar/index.d.ts",
389
+ "bun": "./src/react/place-bar/index.ts",
390
+ "source": "./src/react/place-bar/index.ts",
391
+ "import": "./dist/react/place-bar/index.js"
392
+ },
387
393
  "./react/popover": {
388
394
  "types": "./dist/react/components/Popover.d.ts",
389
395
  "bun": "./src/react/components/Popover.tsx",
@@ -420,6 +426,12 @@
420
426
  "source": "./src/react/components/SecretInput.tsx",
421
427
  "import": "./dist/react/components/SecretInput.js"
422
428
  },
429
+ "./react/native-select": {
430
+ "types": "./dist/react/components/NativeSelect.d.ts",
431
+ "bun": "./src/react/components/NativeSelect.tsx",
432
+ "source": "./src/react/components/NativeSelect.tsx",
433
+ "import": "./dist/react/components/NativeSelect.js"
434
+ },
423
435
  "./react/select-field": {
424
436
  "types": "./dist/react/components/SelectField.d.ts",
425
437
  "bun": "./src/react/components/SelectField.tsx",
@@ -492,6 +504,12 @@
492
504
  "source": "./src/react/components/MultiSelectField.tsx",
493
505
  "import": "./dist/react/components/MultiSelectField.js"
494
506
  },
507
+ "./react/section-nav": {
508
+ "types": "./dist/react/components/SectionNav.d.ts",
509
+ "bun": "./src/react/components/SectionNav.tsx",
510
+ "source": "./src/react/components/SectionNav.tsx",
511
+ "import": "./dist/react/components/SectionNav.js"
512
+ },
495
513
  "./react/segmented-control": {
496
514
  "types": "./dist/react/components/SegmentedControl.d.ts",
497
515
  "bun": "./src/react/components/SegmentedControl.tsx",
@@ -594,6 +612,7 @@
594
612
  "./styles-areas/overlays.css": "./src/styles-areas/overlays.css",
595
613
  "./styles-areas/palette.css": "./src/styles-areas/palette.css",
596
614
  "./styles-areas/pdf-viewer.css": "./src/styles-areas/pdf-viewer.css",
615
+ "./styles-areas/place-bar.css": "./src/styles-areas/place-bar.css",
597
616
  "./styles-areas/problem-detail.css": "./src/styles-areas/problem-detail.css",
598
617
  "./styles-areas/rich-text.css": "./src/styles-areas/rich-text.css",
599
618
  "./styles-areas/sharing.css": "./src/styles-areas/sharing.css",
@@ -890,7 +909,7 @@
890
909
  "clsx": "^2.1.1",
891
910
  "cmdk": "^1.1.1",
892
911
  "cursedbelt-core": "^2.10.0",
893
- "cwip": "^4.6.0",
912
+ "cwip": "^4.12.0",
894
913
  "lucide-react": "^1.21.0",
895
914
  "react-hook-form": "^7.80.0",
896
915
  "react-resizable-panels": "^4.12.0",
@@ -174,6 +174,7 @@ export const AREAS: readonly Area[] = [
174
174
  'components/InfoHint.tsx',
175
175
  'components/InfoHintImpl.tsx',
176
176
  'components/Input.tsx',
177
+ 'components/ListPanel.tsx',
177
178
  'components/Menu.tsx',
178
179
  'components/Modal.tsx',
179
180
  'components/OnlineDot.tsx',
@@ -266,6 +267,8 @@ export const AREAS: readonly Area[] = [
266
267
  'components/RenderField.tsx',
267
268
  'components/SearchInput.tsx',
268
269
  'components/SecretInput.tsx',
270
+ // A settings page's section column / phone strip (forge task 2752).
271
+ 'components/SectionNav.tsx',
269
272
  'components/SliderField.tsx',
270
273
  'components/StarRating.tsx',
271
274
  'components/SwatchPicker.tsx',
@@ -341,6 +344,11 @@ export const AREAS: readonly Area[] = [
341
344
  },
342
345
  { name: 'palette', why: 'the ⌘K command palette and its hotkey/source hooks', roots: ['palette'] },
343
346
  { name: 'pdf-viewer', why: 'the lazy PDF viewer (an optional peer already)', roots: ['pdf-viewer'] },
347
+ {
348
+ name: 'place-bar',
349
+ why: 'the `cursedbelt/react/place-bar` phone bottom bar, for an app that mounts it without the AppShell (desk)',
350
+ roots: ['place-bar'],
351
+ },
344
352
  { name: 'problem-detail', why: 'the RFC-7807 problem renderer', roots: ['problem-detail'] },
345
353
  { name: 'rich-text', why: 'the TipTap editor and its toolbar', roots: ['rich-text'] },
346
354
  { name: 'sharing', why: 'the share sheet and its permission rows', roots: ['sharing', 'components/sharing'] },
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
- import { ActivityBar, type ActivityBarItem } from './ActivityBar.js';
3
+ import { ActivityBar, type ActivityBarItem, EXPAND_HOVER_MS, EXPAND_PRESS_MS } from './ActivityBar.js';
4
4
 
5
5
  /**
6
6
  * What is worth pinning here.
@@ -125,3 +125,108 @@ describe("a consumer's own widget", () => {
125
125
  expect(screen.queryByLabelText('Account')).toBeNull();
126
126
  });
127
127
  });
128
+
129
+ describe('the rail shows its words on demand (NAV-2, forge task 2748)', () => {
130
+ /*
131
+ * What is pinned is the STATE machine — `data-expanded` and which way in set it — because
132
+ * the width and the visible label are CSS, which happy-dom does not compute. The browser
133
+ * spec in desk (`test/browser/rail-words.test.ts`) measures the overlay itself.
134
+ */
135
+ const rail = () => screen.getByRole('group');
136
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
137
+ const pointer = (pointerType: string, x = 10, y = 10) => ({ pointerType, clientX: x, clientY: y, bubbles: true });
138
+
139
+ test('every control carries its word, hidden until the rail expands', () => {
140
+ render(<ActivityBar top={[item()]} />);
141
+ expect(rail().getAttribute('data-expanded')).toBeNull();
142
+ expect(rail().querySelector('.cbab-label')?.textContent).toBe('Files');
143
+ });
144
+
145
+ test('a mouse resting on the rail expands it, and leaving folds it', async () => {
146
+ render(<ActivityBar top={[item()]} />);
147
+ fireEvent.pointerOver(rail(), pointer('mouse'));
148
+ await wait(EXPAND_HOVER_MS + 50);
149
+ expect(rail().getAttribute('data-expanded')).toBe('hover');
150
+ fireEvent.pointerOut(rail(), pointer('mouse'));
151
+ expect(rail().getAttribute('data-expanded')).toBeNull();
152
+ });
153
+
154
+ test('a mouse that only crosses the rail does not expand it', async () => {
155
+ render(<ActivityBar top={[item()]} />);
156
+ fireEvent.pointerOver(rail(), pointer('mouse'));
157
+ fireEvent.pointerOut(rail(), pointer('mouse'));
158
+ await wait(EXPAND_HOVER_MS + 50);
159
+ expect(rail().getAttribute('data-expanded')).toBeNull();
160
+ });
161
+
162
+ test('a touch-and-hold expands it, and the hold does not press the button', async () => {
163
+ let pressed = 0;
164
+ render(<ActivityBar top={[item({ onSelect: () => pressed++ })]} />);
165
+ const button = screen.getByLabelText('Files');
166
+ fireEvent.pointerDown(button, pointer('touch'));
167
+ await wait(EXPAND_PRESS_MS + 50);
168
+ expect(rail().getAttribute('data-expanded')).toBe('press');
169
+ fireEvent.pointerUp(button, pointer('touch'));
170
+ fireEvent.click(button);
171
+ expect(pressed).toBe(0);
172
+ // A tap now — the labels on screen — is the button's own, and folds the rail.
173
+ fireEvent.pointerDown(button, pointer('touch'));
174
+ fireEvent.pointerUp(button, pointer('touch'));
175
+ fireEvent.click(button);
176
+ expect(pressed).toBe(1);
177
+ expect(rail().getAttribute('data-expanded')).toBeNull();
178
+ });
179
+
180
+ test('a touch that drifts is a scroll, not a hold', async () => {
181
+ render(<ActivityBar top={[item()]} />);
182
+ const button = screen.getByLabelText('Files');
183
+ fireEvent.pointerDown(button, pointer('touch', 10, 10));
184
+ fireEvent.pointerMove(button, pointer('touch', 10, 40));
185
+ await wait(EXPAND_PRESS_MS + 50);
186
+ expect(rail().getAttribute('data-expanded')).toBeNull();
187
+ });
188
+
189
+ test('a touch outside folds a held-open rail', async () => {
190
+ render(
191
+ <>
192
+ <ActivityBar top={[item()]} />
193
+ <p>page</p>
194
+ </>,
195
+ );
196
+ fireEvent.pointerDown(screen.getByLabelText('Files'), pointer('touch'));
197
+ await wait(EXPAND_PRESS_MS + 50);
198
+ fireEvent.pointerUp(screen.getByLabelText('Files'), pointer('touch'));
199
+ expect(rail().getAttribute('data-expanded')).toBe('press');
200
+ fireEvent.pointerDown(screen.getByText('page'), pointer('touch'));
201
+ expect(rail().getAttribute('data-expanded')).toBeNull();
202
+ });
203
+
204
+ test('Escape folds it', async () => {
205
+ render(<ActivityBar top={[item()]} />);
206
+ fireEvent.pointerOver(rail(), pointer('mouse'));
207
+ await wait(EXPAND_HOVER_MS + 50);
208
+ fireEvent.keyDown(screen.getByLabelText('Files'), { key: 'Escape' });
209
+ expect(rail().getAttribute('data-expanded')).toBeNull();
210
+ });
211
+
212
+ test('expandable={false} keeps the icon-only column', async () => {
213
+ render(<ActivityBar top={[item()]} expandable={false} />);
214
+ fireEvent.pointerOver(rail(), pointer('mouse'));
215
+ await wait(EXPAND_HOVER_MS + 50);
216
+ expect(rail().getAttribute('data-expanded')).toBeNull();
217
+ });
218
+
219
+ test('a count rides the expanded row, and zero draws nothing', () => {
220
+ render(<ActivityBar top={[item({ count: 1200 }), item({ id: 'trash', label: 'Trash', count: 0 })]} />);
221
+ const counts = rail().querySelectorAll('.cbab-count');
222
+ expect(counts).toHaveLength(1);
223
+ expect(counts[0]?.textContent).toBe((1200).toLocaleString());
224
+ });
225
+
226
+ test('onSelect is handed the pressed button, so a menu can anchor to it', () => {
227
+ const triggers: HTMLButtonElement[] = [];
228
+ render(<ActivityBar top={[item({ onSelect: (node) => triggers.push(node) })]} />);
229
+ fireEvent.click(screen.getByLabelText('Files'));
230
+ expect(triggers).toEqual([screen.getByLabelText('Files') as HTMLButtonElement]);
231
+ });
232
+ });
@@ -49,6 +49,20 @@
49
49
  * {@link ActivityBarItem.render} takes the consumer's own popover, and
50
50
  * {@link ActivityBarItem.hasMenu}/{@link ActivityBarItem.menuOpen} say so to a screen reader.
51
51
  *
52
+ * ── 🔴 It shows its WORDS on demand (NAV-2, forge task 2748) ───────────────────────────
53
+ * An icon-only rail is a row of riddles: `DESIGN.md`'s NAV-2 wants every navigation surface
54
+ * to be able to say what each glyph is without a hover on each one. So the column stays 48px
55
+ * and EXPANDS, as an overlay over whatever is beside it (nothing reflows), into icon + label
56
+ * rows — the hover-expand rail Gmail and the Azure portal draw, and the labelled activity
57
+ * bar VS Code's own users keep asking for. Three ways in, one for each kind of hand:
58
+ * · a mouse resting on the rail for {@link EXPAND_HOVER_MS} (the delay is what lets a pointer
59
+ * cross the rail on its way to the sidebar without the rail flaring at it);
60
+ * · keyboard focus arriving in it (`:focus-visible`), so tabbing reads the words too;
61
+ * · a touch-and-hold of {@link EXPAND_PRESS_MS} on any control — the hold is swallowed, so
62
+ * reading the labels never also presses the button under the thumb.
63
+ * It folds again when the mouse leaves, focus leaves, a touch lands outside it, Escape is
64
+ * pressed, or a control is chosen. {@link ActivityBarProps.expandable} `false` opts out.
65
+ *
52
66
  * ── Plain CSS, deliberately ─────────────────────────────────────────────────────────────
53
67
  * `activityBar.css` under the `cbab-` prefix, the same convention (and the same reason) as
54
68
  * `fileTree.css` and `denseGallery.css`: this mounts inside apps whose Tailwind build cannot
@@ -56,7 +70,18 @@
56
70
  * ships a NARROWED `@source` list and has already shipped one unstyled control from exactly
57
71
  * that gap.
58
72
  */
59
- import type { JSX, ReactNode } from 'react';
73
+ import {
74
+ type FocusEvent,
75
+ type JSX,
76
+ type KeyboardEvent,
77
+ type MouseEvent,
78
+ type PointerEvent,
79
+ type ReactNode,
80
+ useCallback,
81
+ useEffect,
82
+ useRef,
83
+ useState,
84
+ } from 'react';
60
85
  import { Tooltip } from '../components/Tooltip.js';
61
86
  import './activityBar.css';
62
87
 
@@ -87,7 +112,12 @@ export interface ActivityBarItem {
87
112
  icon?: ReactNode;
88
113
  /** Drawn as the view you are in — a hairline, not a box. See the file header. */
89
114
  active?: boolean;
90
- onSelect?: () => void;
115
+ /**
116
+ * The press. It is handed the button that was pressed, so a control that opens a popover can
117
+ * anchor it — and name it as the trigger, so a second press closes rather than re-opens —
118
+ * without needing a {@link render} slot of its own.
119
+ */
120
+ onSelect?: (trigger: HTMLButtonElement) => void;
91
121
  /**
92
122
  * Why this cannot act right now. Present ⇒ the control is DISABLED and says why on hover,
93
123
  * never hidden — `DESIGN.md`'s standing rule, and the reason an owner UI is one UI.
@@ -102,6 +132,13 @@ export interface ActivityBarItem {
102
132
  * says *look here*; what the number is belongs to whatever the button opens.
103
133
  */
104
134
  badge?: boolean;
135
+ /**
136
+ * How many — drawn at the END of the expanded row only (Gmail's expanded rail: "Inbox 3"),
137
+ * where there is room for a number at full size. Collapsed, the 20px glyph has none, which
138
+ * is why {@link badge} stays a dot; put the number in {@link title} as well so a hover
139
+ * reads it there. Zero or absent draws nothing.
140
+ */
141
+ count?: number;
105
142
  /**
106
143
  * Render this instead of a button. For a widget the consumer already owns — an account
107
144
  * menu, a fleet launcher — that has to keep its own behaviour and its own popover.
@@ -125,9 +162,24 @@ export interface ActivityBarProps {
125
162
  /** Names the whole column for a screen reader. */
126
163
  ariaLabel?: string;
127
164
  className?: string;
165
+ /**
166
+ * Expand into icon + label rows on a hover, keyboard focus or touch-and-hold — see the file
167
+ * header. Default `true`: NAV-2 is the design law, not a preference. `false` keeps the
168
+ * icon-only column (a rail whose every control is already named beside it).
169
+ */
170
+ expandable?: boolean;
128
171
  }
129
172
 
130
- function ActivityButton({ item }: { item: ActivityBarItem }): JSX.Element {
173
+ /** How long a mouse rests on the rail before it opens its words. */
174
+ export const EXPAND_HOVER_MS = 350;
175
+ /** How long a touch holds before the rail opens its words — Android's long-press. */
176
+ export const EXPAND_PRESS_MS = 450;
177
+ /** A hold that drifts further than this is a scroll or a drag, not a press. */
178
+ const PRESS_SLOP_PX = 10;
179
+
180
+ type ExpandedBy = 'hover' | 'focus' | 'press' | null;
181
+
182
+ function ActivityButton({ item, onChosen }: { item: ActivityBarItem; onChosen: () => void }): JSX.Element {
131
183
  const disabled = item.unavailableReason !== undefined;
132
184
  const button = (
133
185
  <button
@@ -138,7 +190,14 @@ function ActivityButton({ item }: { item: ActivityBarItem }): JSX.Element {
138
190
  type='button'
139
191
  className='cbab-btn'
140
192
  data-active={item.active || undefined}
141
- onClick={disabled ? undefined : item.onSelect}
193
+ onClick={
194
+ disabled
195
+ ? undefined
196
+ : (event: MouseEvent<HTMLButtonElement>) => {
197
+ item.onSelect?.(event.currentTarget);
198
+ onChosen();
199
+ }
200
+ }
142
201
  /*
143
202
  * 🔴 `aria-disabled`, not `disabled`. A disabled button is not focusable and does not
144
203
  * fire the pointer events a tooltip listens for, so the REASON — the only thing that
@@ -152,8 +211,18 @@ function ActivityButton({ item }: { item: ActivityBarItem }): JSX.Element {
152
211
  >
153
212
  <span className='cbab-glyph' aria-hidden='true'>
154
213
  {item.icon}
214
+ {item.badge ? <span className='cbab-badge' /> : null}
155
215
  </span>
156
- {item.badge ? <span className='cbab-badge' aria-hidden='true' /> : null}
216
+ {/* The word. Hidden by the stylesheet until the rail expands; `aria-hidden` because
217
+ `aria-label` already names the button, and a name read twice is noise. */}
218
+ <span className='cbab-label' aria-hidden='true'>
219
+ {item.label}
220
+ </span>
221
+ {item.count ? (
222
+ <span className='cbab-count' aria-hidden='true'>
223
+ {item.count.toLocaleString()}
224
+ </span>
225
+ ) : null}
157
226
  </button>
158
227
  );
159
228
  return (
@@ -163,16 +232,21 @@ function ActivityButton({ item }: { item: ActivityBarItem }): JSX.Element {
163
232
  );
164
233
  }
165
234
 
166
- function Group({ items }: { items: readonly ActivityBarItem[] }): JSX.Element | null {
235
+ function Group({ items, onChosen }: { items: readonly ActivityBarItem[]; onChosen: () => void }): JSX.Element | null {
167
236
  if (items.length === 0) return null;
168
237
  return (
169
238
  <div className='cbab-group'>
170
239
  {items.map((item) =>
171
240
  item.render === undefined ? (
172
- <ActivityButton item={item} key={item.id} />
241
+ <ActivityButton item={item} key={item.id} onChosen={onChosen} />
173
242
  ) : (
174
- <div className='cbab-slot' key={item.id}>
175
- {item.render}
243
+ /* A borrowed widget keeps its own behaviour, so expanded it gets its word BESIDE it
244
+ rather than inside it — the slot is still the 40px square it always was. */
245
+ <div className='cbab-slotrow' key={item.id}>
246
+ <div className='cbab-slot'>{item.render}</div>
247
+ <span className='cbab-label' aria-hidden='true'>
248
+ {item.label}
249
+ </span>
176
250
  </div>
177
251
  ),
178
252
  )}
@@ -180,12 +254,132 @@ function Group({ items }: { items: readonly ActivityBarItem[] }): JSX.Element |
180
254
  );
181
255
  }
182
256
 
257
+ /** `:focus-visible` — a keyboard arrival, not a click. Old engines without it count as one. */
258
+ function focusVisible(node: Element): boolean {
259
+ try {
260
+ return node.matches(':focus-visible');
261
+ } catch {
262
+ return true;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The expand machinery, apart from the markup so the rules read in one place. Each way in
268
+ * remembers WHICH way it came, because each way out only undoes its own: a mouse leaving must
269
+ * not fold a rail the keyboard opened, and a focus leaving must not fold one the hand holds.
270
+ */
271
+ function useExpand(enabled: boolean) {
272
+ const [by, setBy] = useState<ExpandedBy>(null);
273
+ const root = useRef<HTMLFieldSetElement>(null);
274
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
275
+ const press = useRef<{ x: number; y: number; timer?: ReturnType<typeof setTimeout>; held: boolean }>({
276
+ x: 0,
277
+ y: 0,
278
+ held: false,
279
+ });
280
+
281
+ const clearHover = () => {
282
+ if (hoverTimer.current !== undefined) clearTimeout(hoverTimer.current);
283
+ hoverTimer.current = undefined;
284
+ };
285
+ const clearPress = () => {
286
+ if (press.current.timer !== undefined) clearTimeout(press.current.timer);
287
+ press.current.timer = undefined;
288
+ };
289
+ // Refs only, so it is stable across renders — it is handed to every control as `onChosen`.
290
+ const collapse = useCallback(() => {
291
+ clearTimeout(hoverTimer.current);
292
+ hoverTimer.current = undefined;
293
+ clearTimeout(press.current.timer);
294
+ press.current.timer = undefined;
295
+ setBy(null);
296
+ }, []);
297
+
298
+ // Timers die with the rail; a late one would set state on nothing.
299
+ useEffect(
300
+ () => () => {
301
+ clearTimeout(hoverTimer.current);
302
+ clearTimeout(press.current.timer);
303
+ },
304
+ [],
305
+ );
306
+
307
+ // Opened by a hold, it folds on a touch anywhere else — there is no "pointer leaves" on glass.
308
+ useEffect(() => {
309
+ if (by !== 'press') return;
310
+ const outside = (event: globalThis.PointerEvent) => {
311
+ if (!root.current?.contains(event.target as Node)) setBy(null);
312
+ };
313
+ document.addEventListener('pointerdown', outside, true);
314
+ return () => document.removeEventListener('pointerdown', outside, true);
315
+ }, [by]);
316
+
317
+ if (!enabled) return { root, by: null, handlers: {}, collapse };
318
+
319
+ const handlers = {
320
+ onPointerEnter(event: PointerEvent<HTMLFieldSetElement>) {
321
+ if (event.pointerType !== 'mouse' || by !== null) return;
322
+ clearHover();
323
+ hoverTimer.current = setTimeout(() => setBy((current) => current ?? 'hover'), EXPAND_HOVER_MS);
324
+ },
325
+ onPointerLeave(event: PointerEvent<HTMLFieldSetElement>) {
326
+ if (event.pointerType !== 'mouse') return;
327
+ clearHover();
328
+ setBy((current) => (current === 'hover' ? null : current));
329
+ },
330
+ onPointerDown(event: PointerEvent<HTMLFieldSetElement>) {
331
+ if (event.pointerType === 'mouse') return;
332
+ clearPress();
333
+ press.current = { x: event.clientX, y: event.clientY, held: false };
334
+ press.current.timer = setTimeout(() => {
335
+ press.current.held = true;
336
+ press.current.timer = undefined;
337
+ setBy('press');
338
+ }, EXPAND_PRESS_MS);
339
+ },
340
+ onPointerMove(event: PointerEvent<HTMLFieldSetElement>) {
341
+ if (press.current.timer === undefined) return;
342
+ if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > PRESS_SLOP_PX) clearPress();
343
+ },
344
+ onPointerUp: clearPress,
345
+ onPointerCancel: clearPress,
346
+ /* 🔴 The hold is SWALLOWED: the click a lifted thumb fires would otherwise press the very
347
+ control whose label it was reading. Capture phase, so the button never sees it. */
348
+ onClickCapture(event: MouseEvent<HTMLFieldSetElement>) {
349
+ if (!press.current.held) return;
350
+ press.current.held = false;
351
+ event.preventDefault();
352
+ event.stopPropagation();
353
+ },
354
+ // iOS/Android raise their own long-press menu on the same gesture.
355
+ onContextMenu(event: MouseEvent<HTMLFieldSetElement>) {
356
+ if (press.current.held || press.current.timer !== undefined) event.preventDefault();
357
+ },
358
+ onFocus(event: FocusEvent<HTMLFieldSetElement>) {
359
+ if (focusVisible(event.target)) setBy((current) => current ?? 'focus');
360
+ },
361
+ onBlur(event: FocusEvent<HTMLFieldSetElement>) {
362
+ if (root.current?.contains(event.relatedTarget as Node | null)) return;
363
+ setBy((current) => (current === 'focus' ? null : current));
364
+ },
365
+ onKeyDown(event: KeyboardEvent<HTMLFieldSetElement>) {
366
+ if (event.key === 'Escape' && by !== null) {
367
+ event.stopPropagation();
368
+ collapse();
369
+ }
370
+ },
371
+ };
372
+ return { root, by, handlers, collapse };
373
+ }
374
+
183
375
  export function ActivityBar({
184
376
  top,
185
377
  bottom = [],
186
378
  ariaLabel = 'Views and settings',
187
379
  className,
380
+ expandable = true,
188
381
  }: ActivityBarProps): JSX.Element {
382
+ const { root, by, handlers, collapse } = useExpand(expandable);
189
383
  return (
190
384
  /*
191
385
  * A `<fieldset>`, matching `DenseGalleryShell`'s `cbgd-order` next to it — a group of
@@ -193,13 +387,24 @@ export function ActivityBar({
193
387
  * pretending to be. Not `role="toolbar"`: that promises arrow-key navigation between the
194
388
  * buttons, and seven separate tab stops is both honest and better here.
195
389
  */
196
- <fieldset className={className ? `cbab ${className}` : 'cbab'} aria-label={ariaLabel}>
197
- <Group items={top} />
198
- {/* Pushes the application group to the floor. A flex spacer rather than
199
- `justify-content: space-between`, so a rail with an EMPTY bottom group still
200
- starts its top group at the top instead of centring it. */}
201
- <div className='cbab-spacer' />
202
- <Group items={bottom} />
390
+ <fieldset
391
+ ref={root}
392
+ className={className ? `cbab ${className}` : 'cbab'}
393
+ aria-label={ariaLabel}
394
+ data-expandable={expandable || undefined}
395
+ data-expanded={by ?? undefined}
396
+ {...handlers}
397
+ >
398
+ {/* The panel is what widens; the fieldset keeps its 48px track, so expanding is an
399
+ overlay and nothing beside the rail moves. */}
400
+ <div className='cbab-panel'>
401
+ <Group items={top} onChosen={collapse} />
402
+ {/* Pushes the application group to the floor. A flex spacer rather than
403
+ `justify-content: space-between`, so a rail with an EMPTY bottom group still
404
+ starts its top group at the top instead of centring it. */}
405
+ <div className='cbab-spacer' />
406
+ <Group items={bottom} onChosen={collapse} />
407
+ </div>
203
408
  </fieldset>
204
409
  );
205
410
  }