@workbench-kit/react 0.0.2-prototype.0.2.10 → 0.0.2-prototype.0.2.11
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/package.json +10 -10
- package/src/index.ts +16 -2
- package/src/layout/index.ts +9 -0
- package/src/layout/sidebar/SideBarTree.tsx +301 -0
- package/src/layout/sidebar/index.ts +13 -0
- package/src/overlay/ContextMenu.tsx +112 -5
- package/src/overlay/context-menu.css +4 -2
- package/src/primitives/index.ts +5 -0
- package/src/primitives/library-detail-layout/LibraryDetailLayout.tsx +118 -4
- package/src/primitives/library-detail-layout/index.ts +5 -0
- package/src/primitives/library-detail-layout/library-detail-layout.css +57 -0
- package/src/primitives/library-detail-layout/resolve-hero-cover-media.ts +80 -0
- package/src/primitives/record-media-hero/RecordMediaHero.tsx +4 -0
- package/src/primitives/workbench-media-slot/WorkbenchMediaSlot.tsx +7 -1
- package/src/workbench/management/createWorkbenchNotify.tsx +88 -0
- package/src/workbench/management/index.ts +7 -0
- package/src/workbench/management/management.css +28 -0
- package/src/workbench/management/useWorkbenchNotify.ts +10 -0
- package/src/workbench/theme/themePresets.ts +1 -0
- package/src/workbench/workspace/WorkspaceExplorer.tsx +104 -3
- package/src/workbench/workspace/workspaceExplorerKeyboard.ts +109 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workbench-kit/react",
|
|
3
|
-
"version": "0.0.2-prototype.0.2.
|
|
3
|
+
"version": "0.0.2-prototype.0.2.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,15 +58,15 @@
|
|
|
58
58
|
"@vscode/codicons": "^0.0.45",
|
|
59
59
|
"remark-gfm": "^4.0.1",
|
|
60
60
|
"rehype-sanitize": "^6.0.0",
|
|
61
|
-
"@workbench-kit/
|
|
62
|
-
"@workbench-kit/
|
|
63
|
-
"@workbench-kit/
|
|
64
|
-
"@workbench-kit/
|
|
65
|
-
"@workbench-kit/
|
|
66
|
-
"@workbench-kit/
|
|
67
|
-
"@workbench-kit/tokens": "0.0.2-prototype.0.2.
|
|
68
|
-
"@workbench-kit/workspace": "0.0.2-prototype.0.2.
|
|
69
|
-
"@workbench-kit/
|
|
61
|
+
"@workbench-kit/adapters": "0.0.2-prototype.0.2.11",
|
|
62
|
+
"@workbench-kit/monaco": "0.0.2-prototype.0.2.11",
|
|
63
|
+
"@workbench-kit/contracts": "0.0.2-prototype.0.2.11",
|
|
64
|
+
"@workbench-kit/platform": "0.0.2-prototype.0.2.11",
|
|
65
|
+
"@workbench-kit/runtime": "0.0.2-prototype.0.2.11",
|
|
66
|
+
"@workbench-kit/services": "0.0.2-prototype.0.2.11",
|
|
67
|
+
"@workbench-kit/tokens": "0.0.2-prototype.0.2.11",
|
|
68
|
+
"@workbench-kit/workspace": "0.0.2-prototype.0.2.11",
|
|
69
|
+
"@workbench-kit/jdw": "0.0.2-prototype.0.2.11"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
72
|
"react": "^19.0.0",
|
package/src/index.ts
CHANGED
|
@@ -276,6 +276,7 @@ export {
|
|
|
276
276
|
SideBarHeaderControl,
|
|
277
277
|
SideBarList,
|
|
278
278
|
SideBarListItem,
|
|
279
|
+
SideBarTree,
|
|
279
280
|
SideBarViewFrame,
|
|
280
281
|
SideBarRow,
|
|
281
282
|
SidebarActionIconBar,
|
|
@@ -286,12 +287,20 @@ export {
|
|
|
286
287
|
WorkbenchSidebarSection,
|
|
287
288
|
WorkbenchSidebarSectionHeader,
|
|
288
289
|
WorkbenchSidebarSectionStack,
|
|
290
|
+
flattenVisibleSideBarTreeItems,
|
|
291
|
+
isSideBarTreeBranch,
|
|
292
|
+
selectSideBarTreeIds,
|
|
293
|
+
toggleSideBarTreeId,
|
|
289
294
|
useSidebarSectionBaseDepth,
|
|
290
295
|
} from './layout/sidebar';
|
|
291
296
|
export type {
|
|
292
297
|
SideBarHeaderControlProps,
|
|
293
298
|
SideBarListItemProps,
|
|
294
299
|
SideBarListProps,
|
|
300
|
+
SideBarTreeItem,
|
|
301
|
+
SideBarTreeProps,
|
|
302
|
+
SideBarTreeSelectionMode,
|
|
303
|
+
SideBarTreeVisibleNode,
|
|
295
304
|
SideBarViewFrameProps,
|
|
296
305
|
SideBarRowProps,
|
|
297
306
|
SidebarActionIconBarProps,
|
|
@@ -643,8 +652,13 @@ export type {
|
|
|
643
652
|
} from './primitives';
|
|
644
653
|
export { CatalogFilterOverlay } from './primitives';
|
|
645
654
|
export type { CatalogFilterOverlayProps } from './primitives';
|
|
646
|
-
export { LibraryDetailLayout } from './primitives';
|
|
647
|
-
export type {
|
|
655
|
+
export { LibraryDetailLayout, resolveLibraryDetailHeroCoverMedia } from './primitives';
|
|
656
|
+
export type {
|
|
657
|
+
LibraryDetailLayoutMode,
|
|
658
|
+
LibraryDetailLayoutProps,
|
|
659
|
+
ResolveLibraryDetailHeroCoverMediaInput,
|
|
660
|
+
ResolvedLibraryDetailHeroCoverMedia,
|
|
661
|
+
} from './primitives';
|
|
648
662
|
export { WorkbenchMediaPlaceholder } from './primitives';
|
|
649
663
|
export type { WorkbenchMediaPlaceholderProps } from './primitives';
|
|
650
664
|
export { WorkbenchMediaSlot } from './primitives';
|
package/src/layout/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ export {
|
|
|
56
56
|
SideBarListItem,
|
|
57
57
|
SideBarRow,
|
|
58
58
|
SideBarScrollSpacer,
|
|
59
|
+
SideBarTree,
|
|
59
60
|
SideBarViewFrame,
|
|
60
61
|
SideBarViewTabStrip,
|
|
61
62
|
SideBarViewTitleMenu,
|
|
@@ -68,7 +69,11 @@ export {
|
|
|
68
69
|
WorkbenchSidebarSection,
|
|
69
70
|
WorkbenchSidebarSectionHeader,
|
|
70
71
|
WorkbenchSidebarSectionStack,
|
|
72
|
+
flattenVisibleSideBarTreeItems,
|
|
73
|
+
isSideBarTreeBranch,
|
|
74
|
+
selectSideBarTreeIds,
|
|
71
75
|
sideBarTreeDepthStyle,
|
|
76
|
+
toggleSideBarTreeId,
|
|
72
77
|
useSidebarSectionBaseDepth,
|
|
73
78
|
} from './sidebar';
|
|
74
79
|
export type {
|
|
@@ -77,6 +82,10 @@ export type {
|
|
|
77
82
|
SideBarListProps,
|
|
78
83
|
SideBarRowProps,
|
|
79
84
|
SideBarScrollSpacerProps,
|
|
85
|
+
SideBarTreeItem,
|
|
86
|
+
SideBarTreeProps,
|
|
87
|
+
SideBarTreeSelectionMode,
|
|
88
|
+
SideBarTreeVisibleNode,
|
|
80
89
|
SideBarViewFrameProps,
|
|
81
90
|
SideBarViewTabDescriptor,
|
|
82
91
|
SideBarViewTabStripProps,
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useMemo,
|
|
4
|
+
useState,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { cxCodicon } from '../../utils/codicon';
|
|
10
|
+
import { cx } from '../../utils/cx';
|
|
11
|
+
import { SideBarList, SideBarListItem } from './SideBarViewFrame';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Controlled sidebar tree for library / provider category rows.
|
|
15
|
+
*
|
|
16
|
+
* Branch vs leaf: items with `children` (even empty) render as expandable branches;
|
|
17
|
+
* items without `children` are leaves. Hosts own expand/selection sets.
|
|
18
|
+
*
|
|
19
|
+
* Keyboard (default on): ArrowUp/Down move focus+selection (single mode) or focus
|
|
20
|
+
* (multi mode); ArrowRight expands / moves into first child; ArrowLeft collapses /
|
|
21
|
+
* moves to parent; Enter/Space activates selection. Virtualization and DnD are
|
|
22
|
+
* out of scope for v1.
|
|
23
|
+
*/
|
|
24
|
+
export interface SideBarTreeItem {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly label: ReactNode;
|
|
27
|
+
readonly children?: readonly SideBarTreeItem[];
|
|
28
|
+
readonly disabled?: boolean;
|
|
29
|
+
readonly icon?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SideBarTreeSelectionMode = 'single' | 'multi';
|
|
33
|
+
|
|
34
|
+
export interface SideBarTreeProps {
|
|
35
|
+
readonly items: readonly SideBarTreeItem[];
|
|
36
|
+
readonly expandedIds: ReadonlySet<string>;
|
|
37
|
+
readonly selectedIds: ReadonlySet<string>;
|
|
38
|
+
readonly onExpandedIdsChange: (next: Set<string>) => void;
|
|
39
|
+
readonly onSelectedIdsChange: (next: Set<string>) => void;
|
|
40
|
+
readonly selectionMode?: SideBarTreeSelectionMode;
|
|
41
|
+
/** When false, arrow / activation keys are not handled. Default true. */
|
|
42
|
+
readonly keyboardNavigation?: boolean;
|
|
43
|
+
readonly 'aria-label'?: string;
|
|
44
|
+
readonly className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SideBarTreeVisibleNode {
|
|
48
|
+
readonly depth: number;
|
|
49
|
+
readonly item: SideBarTreeItem;
|
|
50
|
+
readonly parentId: string | null;
|
|
51
|
+
readonly hasChildren: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSideBarTreeBranch(item: SideBarTreeItem): boolean {
|
|
55
|
+
return item.children !== undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function flattenVisibleSideBarTreeItems(
|
|
59
|
+
items: readonly SideBarTreeItem[],
|
|
60
|
+
expandedIds: ReadonlySet<string>,
|
|
61
|
+
depth = 0,
|
|
62
|
+
parentId: string | null = null,
|
|
63
|
+
): SideBarTreeVisibleNode[] {
|
|
64
|
+
const rows: SideBarTreeVisibleNode[] = [];
|
|
65
|
+
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
const hasChildren = isSideBarTreeBranch(item);
|
|
68
|
+
rows.push({ depth, item, parentId, hasChildren });
|
|
69
|
+
|
|
70
|
+
if (hasChildren && expandedIds.has(item.id) && item.children) {
|
|
71
|
+
rows.push(...flattenVisibleSideBarTreeItems(item.children, expandedIds, depth + 1, item.id));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function toggleSideBarTreeId(ids: ReadonlySet<string>, id: string): Set<string> {
|
|
79
|
+
const next = new Set(ids);
|
|
80
|
+
if (next.has(id)) {
|
|
81
|
+
next.delete(id);
|
|
82
|
+
} else {
|
|
83
|
+
next.add(id);
|
|
84
|
+
}
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function selectSideBarTreeIds(
|
|
89
|
+
current: ReadonlySet<string>,
|
|
90
|
+
id: string,
|
|
91
|
+
mode: SideBarTreeSelectionMode,
|
|
92
|
+
additive: boolean,
|
|
93
|
+
): Set<string> {
|
|
94
|
+
if (mode === 'single' || !additive) {
|
|
95
|
+
return new Set([id]);
|
|
96
|
+
}
|
|
97
|
+
return toggleSideBarTreeId(current, id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function SideBarTree({
|
|
101
|
+
items,
|
|
102
|
+
expandedIds,
|
|
103
|
+
selectedIds,
|
|
104
|
+
onExpandedIdsChange,
|
|
105
|
+
onSelectedIdsChange,
|
|
106
|
+
selectionMode = 'single',
|
|
107
|
+
keyboardNavigation = true,
|
|
108
|
+
'aria-label': ariaLabel = 'Sidebar tree',
|
|
109
|
+
className,
|
|
110
|
+
}: SideBarTreeProps) {
|
|
111
|
+
const visibleNodes = useMemo(
|
|
112
|
+
() => flattenVisibleSideBarTreeItems(items, expandedIds),
|
|
113
|
+
[expandedIds, items],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const [focusedId, setFocusedId] = useState<string | null>(null);
|
|
117
|
+
|
|
118
|
+
const focusIndex = useMemo(() => {
|
|
119
|
+
if (focusedId == null) {
|
|
120
|
+
return visibleNodes.length > 0 ? 0 : -1;
|
|
121
|
+
}
|
|
122
|
+
const index = visibleNodes.findIndex((node) => node.item.id === focusedId);
|
|
123
|
+
return index >= 0 ? index : visibleNodes.length > 0 ? 0 : -1;
|
|
124
|
+
}, [focusedId, visibleNodes]);
|
|
125
|
+
|
|
126
|
+
const toggleExpanded = useCallback(
|
|
127
|
+
(id: string) => {
|
|
128
|
+
onExpandedIdsChange(toggleSideBarTreeId(expandedIds, id));
|
|
129
|
+
},
|
|
130
|
+
[expandedIds, onExpandedIdsChange],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const selectItem = useCallback(
|
|
134
|
+
(id: string, additive: boolean) => {
|
|
135
|
+
onSelectedIdsChange(selectSideBarTreeIds(selectedIds, id, selectionMode, additive));
|
|
136
|
+
setFocusedId(id);
|
|
137
|
+
},
|
|
138
|
+
[onSelectedIdsChange, selectedIds, selectionMode],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const handleItemClick = useCallback(
|
|
142
|
+
(event: MouseEvent<HTMLButtonElement>, node: SideBarTreeVisibleNode) => {
|
|
143
|
+
if (node.item.disabled) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const additive = selectionMode === 'multi' && (event.metaKey || event.ctrlKey);
|
|
148
|
+
selectItem(node.item.id, additive);
|
|
149
|
+
|
|
150
|
+
if (node.hasChildren && !additive) {
|
|
151
|
+
toggleExpanded(node.item.id);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
[selectItem, selectionMode, toggleExpanded],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const handleKeyDown = useCallback(
|
|
158
|
+
(event: KeyboardEvent<HTMLUListElement>) => {
|
|
159
|
+
if (!keyboardNavigation || visibleNodes.length === 0 || focusIndex < 0) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const current = visibleNodes[focusIndex];
|
|
164
|
+
if (!current) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (event.key === 'ArrowDown') {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
const next = visibleNodes[Math.min(visibleNodes.length - 1, focusIndex + 1)];
|
|
171
|
+
if (!next || next.item.disabled) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setFocusedId(next.item.id);
|
|
175
|
+
if (selectionMode === 'single') {
|
|
176
|
+
onSelectedIdsChange(new Set([next.item.id]));
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (event.key === 'ArrowUp') {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
const next = visibleNodes[Math.max(0, focusIndex - 1)];
|
|
184
|
+
if (!next || next.item.disabled) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
setFocusedId(next.item.id);
|
|
188
|
+
if (selectionMode === 'single') {
|
|
189
|
+
onSelectedIdsChange(new Set([next.item.id]));
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (event.key === 'ArrowRight') {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
if (!current.hasChildren || current.item.disabled) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!expandedIds.has(current.item.id)) {
|
|
200
|
+
toggleExpanded(current.item.id);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const firstChild = current.item.children?.[0];
|
|
204
|
+
if (firstChild && !firstChild.disabled) {
|
|
205
|
+
setFocusedId(firstChild.id);
|
|
206
|
+
if (selectionMode === 'single') {
|
|
207
|
+
onSelectedIdsChange(new Set([firstChild.id]));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (event.key === 'ArrowLeft') {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
if (current.hasChildren && expandedIds.has(current.item.id)) {
|
|
216
|
+
toggleExpanded(current.item.id);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (current.parentId) {
|
|
220
|
+
setFocusedId(current.parentId);
|
|
221
|
+
if (selectionMode === 'single') {
|
|
222
|
+
onSelectedIdsChange(new Set([current.parentId]));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
if (current.item.disabled) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
selectItem(current.item.id, selectionMode === 'multi' && (event.metaKey || event.ctrlKey));
|
|
234
|
+
if (current.hasChildren && selectionMode === 'single') {
|
|
235
|
+
toggleExpanded(current.item.id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
[
|
|
240
|
+
expandedIds,
|
|
241
|
+
focusIndex,
|
|
242
|
+
keyboardNavigation,
|
|
243
|
+
onSelectedIdsChange,
|
|
244
|
+
selectItem,
|
|
245
|
+
selectionMode,
|
|
246
|
+
toggleExpanded,
|
|
247
|
+
visibleNodes,
|
|
248
|
+
],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<SideBarList
|
|
253
|
+
aria-label={ariaLabel}
|
|
254
|
+
className={cx('ui-sidebar-tree', className)}
|
|
255
|
+
fill
|
|
256
|
+
role="tree"
|
|
257
|
+
onKeyDown={handleKeyDown}
|
|
258
|
+
>
|
|
259
|
+
{visibleNodes.map((node, index) => {
|
|
260
|
+
const { item, depth, hasChildren } = node;
|
|
261
|
+
const expanded = hasChildren && expandedIds.has(item.id);
|
|
262
|
+
const selected = selectedIds.has(item.id);
|
|
263
|
+
const focused = focusIndex === index;
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<SideBarListItem
|
|
267
|
+
key={item.id}
|
|
268
|
+
depth={depth}
|
|
269
|
+
disabled={item.disabled}
|
|
270
|
+
selected={selected}
|
|
271
|
+
tabIndex={focused ? 0 : -1}
|
|
272
|
+
wrapperProps={{
|
|
273
|
+
role: 'treeitem',
|
|
274
|
+
'aria-level': depth + 1,
|
|
275
|
+
'aria-selected': selected,
|
|
276
|
+
...(hasChildren ? { 'aria-expanded': expanded } : {}),
|
|
277
|
+
}}
|
|
278
|
+
onClick={(event) => handleItemClick(event, node)}
|
|
279
|
+
onFocus={() => setFocusedId(item.id)}
|
|
280
|
+
>
|
|
281
|
+
<span className="workbench-tree-prefix">
|
|
282
|
+
{hasChildren ? (
|
|
283
|
+
<i
|
|
284
|
+
aria-hidden="true"
|
|
285
|
+
className={cxCodicon(
|
|
286
|
+
expanded ? 'chevron-down' : 'chevron-right',
|
|
287
|
+
'workbench-tree-chevron',
|
|
288
|
+
)}
|
|
289
|
+
/>
|
|
290
|
+
) : (
|
|
291
|
+
<span className="workbench-tree-spacer" />
|
|
292
|
+
)}
|
|
293
|
+
{item.icon}
|
|
294
|
+
</span>
|
|
295
|
+
<span className="workbench-tree-label">{item.label}</span>
|
|
296
|
+
</SideBarListItem>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</SideBarList>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -17,6 +17,19 @@ export type {
|
|
|
17
17
|
SideBarScrollSpacerProps,
|
|
18
18
|
SideBarViewFrameProps,
|
|
19
19
|
} from './SideBarViewFrame';
|
|
20
|
+
export {
|
|
21
|
+
SideBarTree,
|
|
22
|
+
flattenVisibleSideBarTreeItems,
|
|
23
|
+
isSideBarTreeBranch,
|
|
24
|
+
selectSideBarTreeIds,
|
|
25
|
+
toggleSideBarTreeId,
|
|
26
|
+
} from './SideBarTree';
|
|
27
|
+
export type {
|
|
28
|
+
SideBarTreeItem,
|
|
29
|
+
SideBarTreeProps,
|
|
30
|
+
SideBarTreeSelectionMode,
|
|
31
|
+
SideBarTreeVisibleNode,
|
|
32
|
+
} from './SideBarTree';
|
|
20
33
|
export { SidebarActionIconBar } from './SidebarActionIconBar';
|
|
21
34
|
export { SidebarSlotChrome } from './SidebarSlotChrome';
|
|
22
35
|
export type { SidebarSlotChromeProps } from './SidebarSlotChrome';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, type ReactNode } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
2
|
import { Button } from '../primitives/button';
|
|
3
3
|
import { cxCodicon } from '../utils/codicon';
|
|
4
4
|
import { cx } from '../utils/cx';
|
|
@@ -44,6 +44,47 @@ function menuHasShortcuts(items: ContextMenuItem[]): boolean {
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function isEnabledMenuItem(
|
|
48
|
+
item: ContextMenuItem,
|
|
49
|
+
): item is Extract<ContextMenuItem, { onSelect: () => void }> {
|
|
50
|
+
return item.type !== 'separator' && !item.disabled;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getEnabledItemIndexes(items: readonly ContextMenuItem[]): number[] {
|
|
54
|
+
return items.reduce<number[]>((indexes, item, index) => {
|
|
55
|
+
if (isEnabledMenuItem(item)) {
|
|
56
|
+
indexes.push(index);
|
|
57
|
+
}
|
|
58
|
+
return indexes;
|
|
59
|
+
}, []);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stepEnabledIndex(
|
|
63
|
+
enabledIndexes: readonly number[],
|
|
64
|
+
currentIndex: number,
|
|
65
|
+
direction: 1 | -1,
|
|
66
|
+
): number {
|
|
67
|
+
if (enabledIndexes.length === 0) {
|
|
68
|
+
return -1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const position = enabledIndexes.indexOf(currentIndex);
|
|
72
|
+
if (position < 0) {
|
|
73
|
+
return direction === 1 ? enabledIndexes[0]! : enabledIndexes[enabledIndexes.length - 1]!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const nextPosition = (position + direction + enabledIndexes.length) % enabledIndexes.length;
|
|
77
|
+
return enabledIndexes[nextPosition]!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* WAI-ARIA menu keyboard model (no nested submenus in this slice):
|
|
82
|
+
* - ArrowUp/ArrowDown move highlight (skip disabled + separators)
|
|
83
|
+
* - Home/End jump to first/last enabled item
|
|
84
|
+
* - Enter/Space activate the highlighted item
|
|
85
|
+
* - Escape closes via {@link useFixedOverlayDismiss}
|
|
86
|
+
* - Roving tabindex: only the highlighted enabled item is tabIndex=0
|
|
87
|
+
*/
|
|
47
88
|
export function ContextMenu({
|
|
48
89
|
ariaLabel = 'Context menu',
|
|
49
90
|
className,
|
|
@@ -56,6 +97,8 @@ export function ContextMenu({
|
|
|
56
97
|
const position = useClampedFixedOverlayPosition(ref, { x, y }, items.length);
|
|
57
98
|
const hasIcons = menuHasIcons(items);
|
|
58
99
|
const hasShortcuts = menuHasShortcuts(items);
|
|
100
|
+
const enabledIndexes = useMemo(() => getEnabledItemIndexes(items), [items]);
|
|
101
|
+
const [highlightedIndex, setHighlightedIndex] = useState(() => enabledIndexes[0] ?? -1);
|
|
59
102
|
|
|
60
103
|
useFixedOverlayDismiss({ containerRef: ref, onClose });
|
|
61
104
|
|
|
@@ -73,8 +116,64 @@ export function ContextMenu({
|
|
|
73
116
|
}, [onClose]);
|
|
74
117
|
|
|
75
118
|
useEffect(() => {
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
setHighlightedIndex((current) =>
|
|
120
|
+
enabledIndexes.includes(current) ? current : (enabledIndexes[0] ?? -1),
|
|
121
|
+
);
|
|
122
|
+
}, [enabledIndexes]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (highlightedIndex < 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const item = ref.current?.querySelector<HTMLButtonElement>(
|
|
130
|
+
`[data-menu-index="${highlightedIndex}"]`,
|
|
131
|
+
);
|
|
132
|
+
item?.focus();
|
|
133
|
+
}, [highlightedIndex]);
|
|
134
|
+
|
|
135
|
+
const activateIndex = (index: number) => {
|
|
136
|
+
const item = items[index];
|
|
137
|
+
if (!item || !isEnabledMenuItem(item)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
item.onSelect();
|
|
141
|
+
onClose();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
145
|
+
if (event.key === 'ArrowDown') {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
setHighlightedIndex((current) => stepEnabledIndex(enabledIndexes, current, 1));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (event.key === 'ArrowUp') {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
setHighlightedIndex((current) => stepEnabledIndex(enabledIndexes, current, -1));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.key === 'Home') {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
setHighlightedIndex(enabledIndexes[0] ?? -1);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (event.key === 'End') {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
setHighlightedIndex(enabledIndexes[enabledIndexes.length - 1] ?? -1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
170
|
+
if (highlightedIndex < 0) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
activateIndex(highlightedIndex);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
78
177
|
|
|
79
178
|
if (items.length === 0) return null;
|
|
80
179
|
|
|
@@ -91,6 +190,7 @@ export function ContextMenu({
|
|
|
91
190
|
top: position.y,
|
|
92
191
|
}}
|
|
93
192
|
onContextMenu={(event) => event.preventDefault()}
|
|
193
|
+
onKeyDown={handleMenuKeyDown}
|
|
94
194
|
>
|
|
95
195
|
{items.map((item, index) =>
|
|
96
196
|
item.type === 'separator' ? (
|
|
@@ -100,11 +200,18 @@ export function ContextMenu({
|
|
|
100
200
|
key={itemKey(item, index)}
|
|
101
201
|
className="ui-context-menu__item"
|
|
102
202
|
data-danger={item.danger ? 'true' : undefined}
|
|
203
|
+
data-highlighted={highlightedIndex === index ? 'true' : undefined}
|
|
204
|
+
data-menu-index={index}
|
|
103
205
|
disabled={item.disabled}
|
|
104
206
|
role="menuitem"
|
|
207
|
+
tabIndex={highlightedIndex === index ? 0 : -1}
|
|
105
208
|
onClick={() => {
|
|
106
|
-
|
|
107
|
-
|
|
209
|
+
activateIndex(index);
|
|
210
|
+
}}
|
|
211
|
+
onMouseEnter={() => {
|
|
212
|
+
if (!item.disabled) {
|
|
213
|
+
setHighlightedIndex(index);
|
|
214
|
+
}
|
|
108
215
|
}}
|
|
109
216
|
>
|
|
110
217
|
{hasIcons ? (
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
.ui-context-menu__item:hover,
|
|
75
|
-
.ui-context-menu__item:focus-visible
|
|
75
|
+
.ui-context-menu__item:focus-visible,
|
|
76
|
+
.ui-context-menu__item[data-highlighted='true'] {
|
|
76
77
|
color: var(--color-text);
|
|
77
78
|
background: var(--color-surface-hover);
|
|
78
79
|
outline: none;
|
|
@@ -92,7 +93,8 @@
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
.ui-context-menu__item[data-danger='true']:hover,
|
|
95
|
-
.ui-context-menu__item[data-danger='true']:focus-visible
|
|
96
|
+
.ui-context-menu__item[data-danger='true']:focus-visible,
|
|
97
|
+
.ui-context-menu__item[data-danger='true'][data-highlighted='true'] {
|
|
96
98
|
color: #fff;
|
|
97
99
|
background: var(--color-danger);
|
|
98
100
|
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -110,6 +110,11 @@ export type {
|
|
|
110
110
|
LibraryDetailLayoutMode,
|
|
111
111
|
LibraryDetailLayoutProps,
|
|
112
112
|
} from './library-detail-layout/LibraryDetailLayout';
|
|
113
|
+
export { resolveLibraryDetailHeroCoverMedia } from './library-detail-layout/resolve-hero-cover-media.js';
|
|
114
|
+
export type {
|
|
115
|
+
ResolveLibraryDetailHeroCoverMediaInput,
|
|
116
|
+
ResolvedLibraryDetailHeroCoverMedia,
|
|
117
|
+
} from './library-detail-layout/resolve-hero-cover-media.js';
|
|
113
118
|
export { ScrollAreaInfiniteSentinel } from './scroll-area-infinite-load/ScrollAreaInfiniteSentinel';
|
|
114
119
|
export type { ScrollAreaInfiniteSentinelProps } from './scroll-area-infinite-load/ScrollAreaInfiniteSentinel';
|
|
115
120
|
export { useScrollAreaInfiniteLoad } from './scroll-area-infinite-load/useScrollAreaInfiniteLoad';
|