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.
- package/dist/react/activity-bar/ActivityBar.d.ts +39 -3
- package/dist/react/activity-bar/ActivityBar.d.ts.map +1 -1
- package/dist/react/activity-bar/ActivityBar.js +214 -6
- package/dist/react/activity-bar/ActivityBar.js.map +1 -1
- package/dist/react/activity-bar/activityBar.css +95 -10
- package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
- package/dist/react/canvas-hud/HudControls.js +1 -1
- package/dist/react/canvas-hud/HudControls.js.map +1 -1
- package/dist/react/components/Button.d.ts +5 -4
- package/dist/react/components/Button.d.ts.map +1 -1
- package/dist/react/components/Button.js +3 -1
- package/dist/react/components/Button.js.map +1 -1
- package/dist/react/components/ListPanel.d.ts +40 -0
- package/dist/react/components/ListPanel.d.ts.map +1 -0
- package/dist/react/components/ListPanel.js +32 -0
- package/dist/react/components/ListPanel.js.map +1 -0
- package/dist/react/components/NativeSelect.d.ts.map +1 -1
- package/dist/react/components/NativeSelect.js +4 -2
- package/dist/react/components/NativeSelect.js.map +1 -1
- package/dist/react/components/SectionNav.d.ts +39 -0
- package/dist/react/components/SectionNav.d.ts.map +1 -0
- package/dist/react/components/SectionNav.js +76 -0
- package/dist/react/components/SectionNav.js.map +1 -0
- package/dist/react/components/Tooltip.d.ts +4 -2
- package/dist/react/components/Tooltip.d.ts.map +1 -1
- package/dist/react/components/Tooltip.js.map +1 -1
- package/dist/react/components/TooltipImpl.d.ts +0 -5
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +68 -0
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/components/TreeView.js +14 -1
- package/dist/react/components/TreeView.js.map +1 -1
- package/dist/react/disk-usage/DiskUsageTree.d.ts +28 -3
- package/dist/react/disk-usage/DiskUsageTree.d.ts.map +1 -1
- package/dist/react/disk-usage/DiskUsageTree.js +93 -15
- package/dist/react/disk-usage/DiskUsageTree.js.map +1 -1
- package/dist/react/index.d.ts +2 -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/cn.d.ts.map +1 -1
- package/dist/react/lib/cn.js +11 -1
- package/dist/react/lib/cn.js.map +1 -1
- package/dist/react/lib/infoHint.d.ts +6 -2
- package/dist/react/lib/infoHint.d.ts.map +1 -1
- package/dist/react/lib/infoHint.js +6 -2
- package/dist/react/lib/infoHint.js.map +1 -1
- package/dist/react/nav/PlaceBar.d.ts.map +1 -1
- package/dist/react/nav/PlaceBar.js +9 -4
- package/dist/react/nav/PlaceBar.js.map +1 -1
- package/dist/react/place-bar/index.d.ts +2 -0
- package/dist/react/place-bar/index.d.ts.map +1 -0
- package/dist/react/place-bar/index.js +9 -0
- package/dist/react/place-bar/index.js.map +1 -0
- package/dist/styles-areas/analytics.css +11 -11
- package/dist/styles-areas/calendar.css +14 -15
- package/dist/styles-areas/chart-table.css +2 -2
- package/dist/styles-areas/core.css +36 -34
- package/dist/styles-areas/dashboard-grid.css +3 -3
- package/dist/styles-areas/disk-usage.css +5 -4
- package/dist/styles-areas/fields.css +4 -3
- package/dist/styles-areas/layout-engine.css +10 -10
- package/dist/styles-areas/media.css +10 -10
- package/dist/styles-areas/nav.css +7 -7
- package/dist/styles-areas/place-bar.css +33 -0
- package/dist/styles-areas/workbench.css +3 -3
- package/dist/styles-utilities.css +40 -0
- package/package.json +21 -2
- package/scripts/styleAreas.ts +8 -0
- package/src/react/activity-bar/ActivityBar.spec.tsx +106 -1
- package/src/react/activity-bar/ActivityBar.tsx +221 -16
- package/src/react/activity-bar/activityBar.css +95 -10
- package/src/react/canvas-hud/HudControls.tsx +0 -1
- package/src/react/components/Button.tsx +9 -5
- package/src/react/components/InfoHint.spec.tsx +10 -0
- package/src/react/components/ListPanel.spec.tsx +73 -0
- package/src/react/components/ListPanel.tsx +97 -0
- package/src/react/components/NativeSelect.spec.tsx +10 -0
- package/src/react/components/NativeSelect.tsx +4 -2
- package/src/react/components/SectionNav.spec.tsx +98 -0
- package/src/react/components/SectionNav.tsx +143 -0
- package/src/react/components/Tooltip.tsx +4 -2
- package/src/react/components/TooltipImpl.tsx +74 -1
- package/src/react/components/TreeView.spec.tsx +28 -0
- package/src/react/components/TreeView.tsx +12 -1
- package/src/react/disk-usage/DiskUsage.spec.tsx +96 -0
- package/src/react/disk-usage/DiskUsageTree.tsx +193 -29
- package/src/react/index.ts +3 -0
- package/src/react/lib/cn.spec.ts +7 -0
- package/src/react/lib/cn.ts +11 -1
- package/src/react/lib/infoHint.ts +6 -2
- package/src/react/nav/PlaceBar.tsx +9 -5
- package/src/react/place-bar/index.ts +8 -0
- package/src/styles-areas/analytics.css +11 -11
- package/src/styles-areas/calendar.css +14 -15
- package/src/styles-areas/chart-table.css +2 -2
- package/src/styles-areas/core.css +36 -34
- package/src/styles-areas/dashboard-grid.css +3 -3
- package/src/styles-areas/disk-usage.css +5 -4
- package/src/styles-areas/fields.css +4 -3
- package/src/styles-areas/layout-engine.css +10 -10
- package/src/styles-areas/media.css +10 -10
- package/src/styles-areas/nav.css +7 -7
- package/src/styles-areas/place-bar.css +33 -0
- package/src/styles-areas/workbench.css +3 -3
- package/src/styles-utilities.css +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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",
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
{
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
}
|