@workbench-kit/react 0.0.2-prototype.0.2.4 → 0.0.2-prototype.0.2.40

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 (221) hide show
  1. package/README.md +172 -0
  2. package/package.json +38 -21
  3. package/src/index.ts +74 -34
  4. package/src/jdw/JsonCodeEditorPane.tsx +7 -6
  5. package/src/jdw/builtins/renderBuiltinWidgetLeaf.tsx +1 -3
  6. package/src/jdw/createBuiltinJdwRegistry.ts +1 -4
  7. package/src/jdw/cssRenderBackend.tsx +1 -4
  8. package/src/jdw/fixtures/jdw-fixtures.ts +0 -15
  9. package/src/json-config/JsonConfigWorkbench.tsx +1 -1
  10. package/src/layout/index.ts +11 -2
  11. package/src/layout/panel/Panel.tsx +0 -3
  12. package/src/layout/panel/index.ts +0 -2
  13. package/src/layout/sidebar/SideBarTree.tsx +301 -0
  14. package/src/layout/sidebar/SidebarActionIconBar.tsx +3 -3
  15. package/src/layout/sidebar/WorkbenchSidebarActions.tsx +1 -1
  16. package/src/layout/sidebar/index.ts +13 -0
  17. package/src/modal/Modal.tsx +55 -25
  18. package/src/modal/ModalTitlebar.tsx +8 -8
  19. package/src/modal/modalPosition.ts +3 -6
  20. package/src/modal/useModalFocusTrap.ts +142 -0
  21. package/src/modal/useModalWindowFrame.ts +2 -2
  22. package/src/overlay/ContextMenu.tsx +133 -21
  23. package/src/overlay/context-menu-density.css +22 -0
  24. package/src/overlay/context-menu-item.ts +17 -0
  25. package/src/overlay/context-menu.css +6 -2
  26. package/src/overlay/index.ts +6 -1
  27. package/src/overlay/measureAnchoredOverlayPanel.ts +4 -6
  28. package/src/overlay/pointerPassthroughRegion.ts +17 -1
  29. package/src/overlay/usePointerPassthroughRegion.ts +35 -6
  30. package/src/primitives/absolute-box/AbsoluteBox.tsx +4 -4
  31. package/src/primitives/app-icon/AppIcon.tsx +33 -0
  32. package/src/primitives/app-icon/app-icon.css +28 -0
  33. package/src/primitives/button/Button.tsx +4 -4
  34. package/src/primitives/catalog-browse-card/CatalogBrowseCard.tsx +6 -6
  35. package/src/primitives/catalog-browse-pane/CatalogBrowsePane.tsx +39 -32
  36. package/src/primitives/catalog-browse-pane/catalog-browse-pane.css +4 -0
  37. package/src/primitives/catalog-filter-overlay/CatalogFilterOverlay.tsx +2 -2
  38. package/src/primitives/checkbox/Checkbox.tsx +1 -1
  39. package/src/primitives/chip/Chip.tsx +3 -12
  40. package/src/primitives/chip/index.ts +2 -2
  41. package/src/primitives/codicon/Codicon.tsx +1 -1
  42. package/src/primitives/file-icon/FileIcon.tsx +1 -1
  43. package/src/primitives/icon-button/IconButton.tsx +1 -1
  44. package/src/primitives/index.ts +7 -2
  45. package/src/primitives/library-detail-layout/LibraryDetailLayout.tsx +123 -9
  46. package/src/primitives/library-detail-layout/library-detail-layout.css +57 -0
  47. package/src/primitives/library-detail-layout/resolve-hero-cover-media.ts +80 -0
  48. package/src/primitives/library-facet-filter-panel/LibraryFacetFilterPanel.tsx +12 -12
  49. package/src/primitives/library-facet-filter-strip/LibraryFacetFilterStrip.tsx +4 -4
  50. package/src/primitives/number-input/NumberInput.tsx +5 -5
  51. package/src/primitives/primitives.css +1 -0
  52. package/src/primitives/record-media-hero/RecordMediaHero.tsx +10 -6
  53. package/src/primitives/scroll-area/ScrollArea.tsx +5 -5
  54. package/src/primitives/scroll-area-infinite-load/useScrollAreaInfiniteLoad.ts +5 -5
  55. package/src/primitives/searchable-multi-select/SearchableMultiSelect.tsx +7 -7
  56. package/src/primitives/select/Select.tsx +1 -13
  57. package/src/primitives/text-area/TextArea.tsx +1 -1
  58. package/src/primitives/text-input/TextInput.tsx +1 -1
  59. package/src/primitives/workbench-media-slot/WorkbenchMediaPlaceholder.tsx +2 -2
  60. package/src/primitives/workbench-media-slot/WorkbenchMediaSlot.tsx +16 -10
  61. package/src/primitives/workbench-thumbnail/WorkbenchThumbnail.tsx +4 -4
  62. package/src/scrollbars.css +2 -6
  63. package/src/styles/core.css +16 -0
  64. package/src/styles/foundation.css +4 -0
  65. package/src/styles/overlay.css +2 -0
  66. package/src/styles.css +1 -15
  67. package/src/utils/clamp.ts +4 -0
  68. package/src/utils/normalizeKeyToken.ts +13 -0
  69. package/src/utils/readNumber.ts +4 -0
  70. package/src/widget-tree/WidgetSourceEditor.tsx +0 -14
  71. package/src/widget-tree/WidgetTreeCanvasPreview.tsx +3 -10
  72. package/src/widget-tree/WidgetTreeLab.tsx +2 -23
  73. package/src/widget-tree/WidgetTreeSidePanel.tsx +0 -5
  74. package/src/widget-tree/WidgetTreeWorkspaceShell.tsx +2 -2
  75. package/src/widget-tree/index.ts +1 -5
  76. package/src/workbench/auth/WorkbenchLoginView.tsx +1 -2
  77. package/src/workbench/auth/auth-flows.css +14 -0
  78. package/src/workbench/chat/ChatCommandProposalCard.tsx +4 -4
  79. package/src/workbench/chat/ChatConversationBar.tsx +3 -3
  80. package/src/workbench/chat/ChatHistoryMenu.tsx +1 -1
  81. package/src/workbench/chat/ChatMessageCollapsible.tsx +4 -0
  82. package/src/workbench/chat/ChatMessageItem.tsx +68 -10
  83. package/src/workbench/chat/ChatMessageList.tsx +1 -1
  84. package/src/workbench/chat/ChatPanel.tsx +32 -23
  85. package/src/workbench/chat/ChatPhasedRunProgress.tsx +86 -13
  86. package/src/workbench/chat/ChatRenameDialog.tsx +1 -1
  87. package/src/workbench/chat/chat-conversation.css +2 -2
  88. package/src/workbench/chat/chat-ui.css +8 -0
  89. package/src/workbench/chat/chatConversation.ts +2 -2
  90. package/src/workbench/chat/chatMessageMeta.tsx +4 -4
  91. package/src/workbench/chat/index.ts +1 -0
  92. package/src/workbench/chat/types.ts +7 -7
  93. package/src/workbench/chat/useCancelRuntimeOnUnmount.ts +4 -4
  94. package/src/workbench/chat/useChatPrependPagination.ts +10 -10
  95. package/src/workbench/chat/useSlashCommandSuggest.ts +1 -1
  96. package/src/workbench/{WorkbenchModalPortal.tsx → chrome/WorkbenchModalPortal.tsx} +1 -1
  97. package/src/workbench/chrome/workbench-chrome.css +4 -0
  98. package/src/workbench/chrome/workbench-layout-regions.css +13 -0
  99. package/src/workbench/chrome/workbench-shell-titlebar.css +0 -8
  100. package/src/workbench/{CommandGroupShell.tsx → commands/CommandGroupShell.tsx} +2 -2
  101. package/src/workbench/{CommandList.tsx → commands/CommandList.tsx} +6 -5
  102. package/src/workbench/{CommandPalette.tsx → commands/CommandPalette.tsx} +16 -42
  103. package/src/workbench/{ConfirmationFlow.tsx → commands/ConfirmationFlow.tsx} +5 -5
  104. package/src/workbench/{ShortcutCommandBridge.tsx → commands/ShortcutCommandBridge.tsx} +17 -13
  105. package/src/workbench/commands/WorkbenchQuickOpen.tsx +462 -0
  106. package/src/workbench/{command-model.ts → commands/command-model.ts} +5 -1
  107. package/src/workbench/commands/command-ui.ts +3 -0
  108. package/src/workbench/{commands.ts → commands/commands.ts} +45 -1
  109. package/src/workbench/commands/createWorkspaceFilesQuickOpenProvider.ts +86 -0
  110. package/src/workbench/commands/quick-open-model.ts +66 -0
  111. package/src/workbench/{WorkbenchEditorTabs.tsx → editor/WorkbenchEditorTabs.tsx} +13 -3
  112. package/src/workbench/{editor-tabs-dnd.ts → editor/editor-tabs-dnd.ts} +1 -1
  113. package/src/workbench/{editorTabContextMenu.ts → editor/editorTabContextMenu.ts} +43 -4
  114. package/src/workbench/{useEditorTabsStripDnd.ts → editor/useEditorTabsStripDnd.ts} +1 -1
  115. package/src/workbench/{useWorkbenchEditorTabContextMenu.tsx → editor/useWorkbenchEditorTabContextMenu.tsx} +16 -2
  116. package/src/workbench/editor-tabs/index.ts +10 -9
  117. package/src/workbench/index.ts +114 -74
  118. package/src/workbench/management/CommandManagementSidebar.tsx +16 -30
  119. package/src/workbench/management/ExtensionManagementPanel.tsx +84 -46
  120. package/src/workbench/management/ExtensionManagementSidebar.tsx +79 -45
  121. package/src/workbench/management/IntegrationCommandActions.tsx +8 -1
  122. package/src/workbench/management/IntegrationSettingsSurface.tsx +2 -5
  123. package/src/workbench/management/IntegrationsShell.tsx +1 -1
  124. package/src/workbench/management/KeybindingCaptureField.tsx +1 -9
  125. package/src/workbench/management/WorkbenchDialogFrame.tsx +2 -2
  126. package/src/workbench/management/WorkbenchNotice.tsx +17 -12
  127. package/src/workbench/management/createWorkbenchNotify.tsx +88 -0
  128. package/src/workbench/management/extension-install-approval.ts +72 -0
  129. package/src/workbench/management/extension-management-filters.ts +38 -0
  130. package/src/workbench/management/index.ts +18 -3
  131. package/src/workbench/management/management.css +28 -0
  132. package/src/workbench/management/types.ts +23 -1
  133. package/src/workbench/management/useWorkbenchNotify.ts +10 -0
  134. package/src/workbench/{MarkdownPreview.tsx → markdown/MarkdownPreview.tsx} +22 -7
  135. package/src/workbench/markdown/markdownRemarkPlugins.ts +12 -0
  136. package/src/workbench/markdown/sanitizeMarkdownHref.ts +29 -0
  137. package/src/workbench/schema/workbenchDocumentRenderer.tsx +48 -121
  138. package/src/workbench/settings/StructuredDataForm.tsx +1 -4
  139. package/src/workbench/settings/StructuredDataSchemaPanel.tsx +39 -44
  140. package/src/workbench/settings/sectionedPanelScrollSpy.ts +0 -81
  141. package/src/workbench/settings/structuredDataFormModel.ts +2 -1
  142. package/src/workbench/settings/structuredDataSchema.ts +24 -13
  143. package/src/workbench/settings/structuredDataSchemaSection.ts +4 -0
  144. package/src/workbench/settings/structuredDataSchemaTable.ts +5 -2
  145. package/src/workbench/{ActivityBar.tsx → shell/ActivityBar.tsx} +8 -12
  146. package/src/workbench/{ArtifactShell.tsx → shell/ArtifactShell.tsx} +25 -25
  147. package/src/workbench/{SplitView.tsx → shell/SplitView.tsx} +3 -3
  148. package/src/workbench/{StatusBar.tsx → shell/StatusBar.tsx} +8 -8
  149. package/src/workbench/{StructuredArtifactEditor.tsx → shell/StructuredArtifactEditor.tsx} +9 -9
  150. package/src/workbench/{Timeline.tsx → shell/Timeline.tsx} +77 -20
  151. package/src/workbench/{WorkbenchCanvasShell.defaults.ts → shell/WorkbenchCanvasShell.defaults.ts} +1 -1
  152. package/src/workbench/{WorkbenchCanvasShell.model.ts → shell/WorkbenchCanvasShell.model.ts} +5 -5
  153. package/src/workbench/{WorkbenchCanvasShell.tsx → shell/WorkbenchCanvasShell.tsx} +6 -6
  154. package/src/workbench/{WorkbenchDesktopTitleBar.tsx → shell/WorkbenchDesktopTitleBar.tsx} +15 -15
  155. package/src/workbench/{WorkbenchShell.tsx → shell/WorkbenchShell.tsx} +45 -6
  156. package/src/workbench/{WorkbenchShellTitleBarLayoutControls.tsx → shell/WorkbenchShellTitleBarLayoutControls.tsx} +16 -19
  157. package/src/workbench/{WorkbenchStandaloneShell.tsx → shell/WorkbenchStandaloneShell.tsx} +15 -22
  158. package/src/workbench/{WorkbenchViewEditor.tsx → shell/WorkbenchViewEditor.tsx} +7 -7
  159. package/src/workbench/{WorkbenchViewSidebar.tsx → shell/WorkbenchViewSidebar.tsx} +7 -7
  160. package/src/workbench/{WorkbenchWindowChromeControls.tsx → shell/WorkbenchWindowChromeControls.tsx} +18 -15
  161. package/src/workbench/shell/activity-bar.css +47 -0
  162. package/src/workbench/{activityBarOrder.ts → shell/activityBarOrder.ts} +2 -1
  163. package/src/workbench/{shell.ts → shell/shell.ts} +20 -13
  164. package/src/workbench/{shellViewModel.ts → shell/shellViewModel.ts} +3 -3
  165. package/src/workbench/{standalone.ts → shell/standalone.ts} +14 -2
  166. package/src/workbench/{useDeferredWorkbenchMount.ts → shell/useDeferredWorkbenchMount.ts} +3 -3
  167. package/src/workbench/{useWorkbenchAppearanceDocumentSync.tsx → shell/useWorkbenchAppearanceDocumentSync.tsx} +1 -1
  168. package/src/workbench/{useWorkbenchSidebarActionBarDnd.ts → shell/useWorkbenchSidebarActionBarDnd.ts} +1 -1
  169. package/src/workbench/{useWorkbenchSidebarViewPlacementDropZone.ts → shell/useWorkbenchSidebarViewPlacementDropZone.ts} +2 -2
  170. package/src/workbench/{workbenchSidebarLayoutContext.ts → shell/workbenchSidebarLayoutContext.ts} +0 -6
  171. package/src/workbench/{workbenchViewRouteState.ts → shell/workbenchViewRouteState.ts} +4 -4
  172. package/src/workbench/{WorkbenchThemeProvider.tsx → theme/WorkbenchThemeProvider.tsx} +4 -4
  173. package/src/workbench/{theme.ts → theme/theme.ts} +1 -1
  174. package/src/workbench/{themePresets.ts → theme/themePresets.ts} +3 -2
  175. package/src/workbench/views/command-list-density.css +36 -0
  176. package/src/workbench/views/command-list.css +2 -0
  177. package/src/workbench/views/extensions-sidebar.css +2 -0
  178. package/src/workbench/views/view-hosts.css +3 -3
  179. package/src/workbench/workspace/WorkspaceEditorPanel.tsx +1 -1
  180. package/src/workbench/workspace/WorkspaceExplorer.tsx +105 -4
  181. package/src/workbench/workspace/createVirtualWorkspaceExplorerPort.ts +1 -14
  182. package/src/workbench/workspace/workspaceExplorerKeyboard.ts +109 -0
  183. package/src/workbench/workspace/workspaceJsonDiagnostics.ts +6 -5
  184. package/src/workbench-menu-surfaces.css +3 -57
  185. package/src/brand/TilepaperAppIcon.tsx +0 -181
  186. package/src/brand/index.ts +0 -2
  187. package/src/brand/tilepaper-app-icon-geometry.ts +0 -201
  188. package/src/primitives/absolute-box/index.ts +0 -1
  189. package/src/primitives/catalog-browse-card/index.ts +0 -1
  190. package/src/primitives/catalog-browse-pane/index.ts +0 -3
  191. package/src/primitives/catalog-filter-overlay/index.ts +0 -1
  192. package/src/primitives/codicon/index.ts +0 -1
  193. package/src/primitives/external-link-button/index.ts +0 -1
  194. package/src/primitives/library-detail-layout/index.ts +0 -1
  195. package/src/primitives/library-facet-filter-panel/index.ts +0 -1
  196. package/src/primitives/library-facet-filter-strip/index.ts +0 -1
  197. package/src/primitives/panel-loading/index.ts +0 -1
  198. package/src/primitives/scroll-area-infinite-load/index.ts +0 -2
  199. package/src/primitives/workbench-thumbnail/index.ts +0 -1
  200. package/src/test-utils/workbenchMonacoMock.tsx +0 -37
  201. package/src/workbench/jdw/jdw-editors.css +0 -4
  202. package/src/workbench/jdw/jdw.css +0 -2
  203. package/src/workbench/management/integration-command-action.ts +0 -25
  204. package/src/workbench/markdownRemarkPlugins.ts +0 -4
  205. package/src/workbench/workbench-chrome.css +0 -4
  206. /package/src/workbench/{WorkbenchPlatformContext.tsx → chrome/WorkbenchPlatformContext.tsx} +0 -0
  207. /package/src/workbench/{workbenchModalViewState.ts → chrome/workbenchModalViewState.ts} +0 -0
  208. /package/src/workbench/{workbenchOverlaysContext.tsx → chrome/workbenchOverlaysContext.tsx} +0 -0
  209. /package/src/workbench/{workbenchPlatformChrome.ts → chrome/workbenchPlatformChrome.ts} +0 -0
  210. /package/src/workbench/{command-execution-policy.ts → commands/command-execution-policy.ts} +0 -0
  211. /package/src/workbench/{keyboard.ts → commands/keyboard.ts} +0 -0
  212. /package/src/workbench/{workbenchContextMenu.ts → commands/workbenchContextMenu.ts} +0 -0
  213. /package/src/workbench/{appearanceLabels.ts → shell/appearanceLabels.ts} +0 -0
  214. /package/src/workbench/{hardReset.ts → shell/hardReset.ts} +0 -0
  215. /package/src/workbench/{shellPresets.ts → shell/shellPresets.ts} +0 -0
  216. /package/src/workbench/{shellState.ts → shell/shellState.ts} +0 -0
  217. /package/src/workbench/{sidebarViewPlacementDnd.ts → shell/sidebarViewPlacementDnd.ts} +0 -0
  218. /package/src/workbench/{status.ts → shell/status.ts} +0 -0
  219. /package/src/workbench/{useWorkbenchSidebarLayout.tsx → shell/useWorkbenchSidebarLayout.tsx} +0 -0
  220. /package/src/workbench/{useWorkbenchStandaloneShellStateSync.tsx → shell/useWorkbenchStandaloneShellStateSync.tsx} +0 -0
  221. /package/src/workbench/{workbenchStandaloneShellReactContext.tsx → shell/workbenchStandaloneShellReactContext.tsx} +0 -0
@@ -0,0 +1,142 @@
1
+ import { useEffect, type RefObject } from 'react';
2
+
3
+ const FOCUSABLE_SELECTOR = [
4
+ 'a[href]',
5
+ 'button:not([disabled])',
6
+ 'textarea:not([disabled])',
7
+ 'input:not([disabled]):not([type="hidden"])',
8
+ 'select:not([disabled])',
9
+ '[tabindex]:not([tabindex="-1"])',
10
+ ].join(',');
11
+
12
+ function isFocusable(element: HTMLElement): boolean {
13
+ if (element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') {
14
+ return false;
15
+ }
16
+ if (element.getAttribute('aria-hidden') === 'true') {
17
+ return false;
18
+ }
19
+ // Explicit tabindex="-1" removes the node from the tab order.
20
+ // Do not use element.tabIndex >= 0: jsdom reports -1 for native buttons.
21
+ if (element.getAttribute('tabindex') === '-1') {
22
+ return false;
23
+ }
24
+ const style = window.getComputedStyle(element);
25
+ return style.display !== 'none' && style.visibility !== 'hidden';
26
+ }
27
+
28
+ function listFocusable(container: HTMLElement): HTMLElement[] {
29
+ return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
30
+ isFocusable,
31
+ );
32
+ }
33
+
34
+ export interface UseModalFocusTrapOptions {
35
+ readonly closeOnEscape?: boolean;
36
+ readonly containerRef: RefObject<HTMLElement | null>;
37
+ /** When false, skip trap/restore (for dialogs that stay mounted while closed). Default true. */
38
+ readonly enabled?: boolean;
39
+ readonly initialFocusRef?: RefObject<HTMLElement | null>;
40
+ readonly onClose: () => void;
41
+ readonly restoreFocusOnClose?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Trap Tab focus inside a mounted modal dialog and restore focus on unmount.
46
+ * Escape dismisses when `closeOnEscape` is true (default).
47
+ */
48
+ export function useModalFocusTrap({
49
+ closeOnEscape = true,
50
+ containerRef,
51
+ enabled = true,
52
+ initialFocusRef,
53
+ onClose,
54
+ restoreFocusOnClose = true,
55
+ }: UseModalFocusTrapOptions): void {
56
+ useEffect(() => {
57
+ if (!enabled) {
58
+ return;
59
+ }
60
+
61
+ const previousFocus =
62
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
63
+
64
+ const focusInitial = () => {
65
+ const container = containerRef.current;
66
+ if (!container) {
67
+ return;
68
+ }
69
+
70
+ const preferred = initialFocusRef?.current;
71
+ if (preferred && container.contains(preferred)) {
72
+ preferred.focus();
73
+ return;
74
+ }
75
+
76
+ const focusables = listFocusable(container);
77
+ if (focusables[0]) {
78
+ focusables[0].focus();
79
+ return;
80
+ }
81
+
82
+ if (!container.hasAttribute('tabindex')) {
83
+ container.tabIndex = -1;
84
+ }
85
+ container.focus();
86
+ };
87
+
88
+ const frame = window.requestAnimationFrame(focusInitial);
89
+
90
+ const handleKeyDown = (event: KeyboardEvent) => {
91
+ if (event.key === 'Escape' && closeOnEscape) {
92
+ event.preventDefault();
93
+ event.stopPropagation();
94
+ onClose();
95
+ return;
96
+ }
97
+
98
+ if (event.key !== 'Tab') {
99
+ return;
100
+ }
101
+
102
+ const container = containerRef.current;
103
+ if (!container) {
104
+ return;
105
+ }
106
+
107
+ const focusables = listFocusable(container);
108
+ if (focusables.length === 0) {
109
+ event.preventDefault();
110
+ container.focus();
111
+ return;
112
+ }
113
+
114
+ const first = focusables[0]!;
115
+ const last = focusables[focusables.length - 1]!;
116
+ const active = document.activeElement;
117
+
118
+ if (event.shiftKey) {
119
+ if (active === first || !container.contains(active)) {
120
+ event.preventDefault();
121
+ last.focus();
122
+ }
123
+ return;
124
+ }
125
+
126
+ if (active === last || !container.contains(active)) {
127
+ event.preventDefault();
128
+ first.focus();
129
+ }
130
+ };
131
+
132
+ window.addEventListener('keydown', handleKeyDown, true);
133
+
134
+ return () => {
135
+ window.cancelAnimationFrame(frame);
136
+ window.removeEventListener('keydown', handleKeyDown, true);
137
+ if (restoreFocusOnClose && previousFocus?.isConnected) {
138
+ previousFocus.focus();
139
+ }
140
+ };
141
+ }, [closeOnEscape, containerRef, enabled, initialFocusRef, onClose, restoreFocusOnClose]);
142
+ }
@@ -9,8 +9,8 @@ import { useModalResize } from './useModalResize';
9
9
 
10
10
  export interface UseModalWindowFrameOptions {
11
11
  defaultMaximized: boolean;
12
- defaultHeight?: number | undefined;
13
- defaultWidth?: number | undefined;
12
+ defaultHeight?: number;
13
+ defaultWidth?: number;
14
14
  minHeight: number;
15
15
  minWidth: number;
16
16
  }
@@ -1,30 +1,20 @@
1
- import { useEffect, useRef, type ReactNode } from 'react';
1
+ import './context-menu.css';
2
+ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
2
3
  import { Button } from '../primitives/button';
3
4
  import { cxCodicon } from '../utils/codicon';
4
5
  import { cx } from '../utils/cx';
6
+ import type { ContextMenuItem } from './context-menu-item';
5
7
  import { useClampedFixedOverlayPosition } from './useClampedFixedOverlayPosition';
6
8
  import { useFixedOverlayDismiss } from './useFixedOverlayDismiss';
7
9
 
8
- export type ContextMenuItem =
9
- | {
10
- type: 'separator';
11
- id?: string | undefined;
12
- }
13
- | {
14
- type?: 'item';
15
- id?: string | undefined;
16
- label: ReactNode;
17
- icon?: string | undefined;
18
- shortcut?: ReactNode | undefined;
19
- disabled?: boolean | undefined;
20
- danger?: boolean | undefined;
21
- onSelect: () => void;
22
- };
10
+ export type { ContextMenuItem } from './context-menu-item';
23
11
 
24
12
  export interface ContextMenuProps {
25
13
  ariaLabel?: string | undefined;
26
14
  className?: string | undefined;
27
15
  items: ContextMenuItem[];
16
+ /** Explicit focus target restored only when Escape dismisses the menu. */
17
+ returnFocusTarget?: HTMLElement | null | undefined;
28
18
  x: number;
29
19
  y: number;
30
20
  onClose: () => void;
@@ -44,20 +34,78 @@ function menuHasShortcuts(items: ContextMenuItem[]): boolean {
44
34
  );
45
35
  }
46
36
 
37
+ function isEnabledMenuItem(
38
+ item: ContextMenuItem,
39
+ ): item is Extract<ContextMenuItem, { onSelect: () => void }> {
40
+ return item.type !== 'separator' && !item.disabled;
41
+ }
42
+
43
+ function getEnabledItemIndexes(items: readonly ContextMenuItem[]): number[] {
44
+ return items.reduce<number[]>((indexes, item, index) => {
45
+ if (isEnabledMenuItem(item)) {
46
+ indexes.push(index);
47
+ }
48
+ return indexes;
49
+ }, []);
50
+ }
51
+
52
+ function stepEnabledIndex(
53
+ enabledIndexes: readonly number[],
54
+ currentIndex: number,
55
+ direction: 1 | -1,
56
+ ): number {
57
+ if (enabledIndexes.length === 0) {
58
+ return -1;
59
+ }
60
+
61
+ const position = enabledIndexes.indexOf(currentIndex);
62
+ if (position < 0) {
63
+ return direction === 1 ? enabledIndexes[0]! : enabledIndexes[enabledIndexes.length - 1]!;
64
+ }
65
+
66
+ const nextPosition = (position + direction + enabledIndexes.length) % enabledIndexes.length;
67
+ return enabledIndexes[nextPosition]!;
68
+ }
69
+
70
+ /**
71
+ * WAI-ARIA menu keyboard model (no nested submenus in this slice):
72
+ * - ArrowUp/ArrowDown move highlight (skip disabled + separators)
73
+ * - Home/End jump to first/last enabled item
74
+ * - Enter/Space activate the highlighted item
75
+ * - Escape closes via {@link useFixedOverlayDismiss}
76
+ * - Roving tabindex: only the highlighted enabled item is tabIndex=0
77
+ */
47
78
  export function ContextMenu({
48
79
  ariaLabel = 'Context menu',
49
80
  className,
50
81
  items,
82
+ returnFocusTarget,
51
83
  x,
52
84
  y,
53
85
  onClose,
54
86
  }: ContextMenuProps) {
55
87
  const ref = useRef<HTMLDivElement>(null);
88
+ const [fallbackReturnFocusTarget] = useState<HTMLElement | null>(() => {
89
+ if (typeof document === 'undefined') {
90
+ return null;
91
+ }
92
+ return document.activeElement instanceof HTMLElement ? document.activeElement : null;
93
+ });
56
94
  const position = useClampedFixedOverlayPosition(ref, { x, y }, items.length);
57
95
  const hasIcons = menuHasIcons(items);
58
96
  const hasShortcuts = menuHasShortcuts(items);
97
+ const enabledIndexes = useMemo(() => getEnabledItemIndexes(items), [items]);
98
+ const [highlightedIndex, setHighlightedIndex] = useState(() => enabledIndexes[0] ?? -1);
59
99
 
60
- useFixedOverlayDismiss({ containerRef: ref, onClose });
100
+ const restoreFocusOnEscape = useCallback(() => {
101
+ const target = returnFocusTarget ?? fallbackReturnFocusTarget;
102
+ if (target?.isConnected) {
103
+ target.focus();
104
+ }
105
+ return false;
106
+ }, [fallbackReturnFocusTarget, returnFocusTarget]);
107
+
108
+ useFixedOverlayDismiss({ containerRef: ref, onClose, onEscape: restoreFocusOnEscape });
61
109
 
62
110
  useEffect(() => {
63
111
  const handleContextMenu = (event: MouseEvent) => {
@@ -73,8 +121,64 @@ export function ContextMenu({
73
121
  }, [onClose]);
74
122
 
75
123
  useEffect(() => {
76
- ref.current?.querySelector<HTMLButtonElement>('.ui-context-menu__item:not(:disabled)')?.focus();
77
- }, []);
124
+ setHighlightedIndex((current) =>
125
+ enabledIndexes.includes(current) ? current : (enabledIndexes[0] ?? -1),
126
+ );
127
+ }, [enabledIndexes]);
128
+
129
+ useEffect(() => {
130
+ if (highlightedIndex < 0) {
131
+ return;
132
+ }
133
+
134
+ const item = ref.current?.querySelector<HTMLButtonElement>(
135
+ `[data-menu-index="${highlightedIndex}"]`,
136
+ );
137
+ item?.focus();
138
+ }, [highlightedIndex]);
139
+
140
+ const activateIndex = (index: number) => {
141
+ const item = items[index];
142
+ if (!item || !isEnabledMenuItem(item)) {
143
+ return;
144
+ }
145
+ item.onSelect();
146
+ onClose();
147
+ };
148
+
149
+ const handleMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
150
+ if (event.key === 'ArrowDown') {
151
+ event.preventDefault();
152
+ setHighlightedIndex((current) => stepEnabledIndex(enabledIndexes, current, 1));
153
+ return;
154
+ }
155
+
156
+ if (event.key === 'ArrowUp') {
157
+ event.preventDefault();
158
+ setHighlightedIndex((current) => stepEnabledIndex(enabledIndexes, current, -1));
159
+ return;
160
+ }
161
+
162
+ if (event.key === 'Home') {
163
+ event.preventDefault();
164
+ setHighlightedIndex(enabledIndexes[0] ?? -1);
165
+ return;
166
+ }
167
+
168
+ if (event.key === 'End') {
169
+ event.preventDefault();
170
+ setHighlightedIndex(enabledIndexes[enabledIndexes.length - 1] ?? -1);
171
+ return;
172
+ }
173
+
174
+ if (event.key === 'Enter' || event.key === ' ') {
175
+ if (highlightedIndex < 0) {
176
+ return;
177
+ }
178
+ event.preventDefault();
179
+ activateIndex(highlightedIndex);
180
+ }
181
+ };
78
182
 
79
183
  if (items.length === 0) return null;
80
184
 
@@ -91,6 +195,7 @@ export function ContextMenu({
91
195
  top: position.y,
92
196
  }}
93
197
  onContextMenu={(event) => event.preventDefault()}
198
+ onKeyDown={handleMenuKeyDown}
94
199
  >
95
200
  {items.map((item, index) =>
96
201
  item.type === 'separator' ? (
@@ -100,11 +205,18 @@ export function ContextMenu({
100
205
  key={itemKey(item, index)}
101
206
  className="ui-context-menu__item"
102
207
  data-danger={item.danger ? 'true' : undefined}
208
+ data-highlighted={highlightedIndex === index ? 'true' : undefined}
209
+ data-menu-index={index}
103
210
  disabled={item.disabled}
104
211
  role="menuitem"
212
+ tabIndex={highlightedIndex === index ? 0 : -1}
105
213
  onClick={() => {
106
- item.onSelect();
107
- onClose();
214
+ activateIndex(index);
215
+ }}
216
+ onMouseEnter={() => {
217
+ if (!item.disabled) {
218
+ setHighlightedIndex(index);
219
+ }
108
220
  }}
109
221
  >
110
222
  {hasIcons ? (
@@ -0,0 +1,22 @@
1
+ /* Compact density overrides for ContextMenu. Keep before context-menu.css. */
2
+
3
+ .ui-context-menu__item,
4
+ .ui-context-menu__item.ui-button {
5
+ --ui-button-min-height: 28px;
6
+ --ui-button-height: auto;
7
+ --ui-button-padding: 4px 10px;
8
+ --ui-button-border-radius: 0;
9
+ border-radius: 0;
10
+ min-height: 28px;
11
+ height: auto;
12
+ padding: 4px 10px;
13
+ }
14
+
15
+ .ui-context-menu__separator {
16
+ margin: 0;
17
+ }
18
+
19
+ .ui-context-menu__shortcut {
20
+ font-size: var(--font-size-xs);
21
+ color: var(--color-text-subtle);
22
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type ContextMenuItem =
4
+ | {
5
+ type: 'separator';
6
+ id?: string | undefined;
7
+ }
8
+ | {
9
+ type?: 'item';
10
+ id?: string | undefined;
11
+ label: ReactNode;
12
+ icon?: string | undefined;
13
+ shortcut?: ReactNode | undefined;
14
+ disabled?: boolean | undefined;
15
+ danger?: boolean | undefined;
16
+ onSelect: () => void;
17
+ };
@@ -1,3 +1,5 @@
1
+ @import './context-menu-density.css';
2
+
1
3
  .ui-context-menu {
2
4
  position: fixed;
3
5
  z-index: var(--ui-context-menu-z-index, 260);
@@ -72,7 +74,8 @@
72
74
  }
73
75
 
74
76
  .ui-context-menu__item:hover,
75
- .ui-context-menu__item:focus-visible {
77
+ .ui-context-menu__item:focus-visible,
78
+ .ui-context-menu__item[data-highlighted='true'] {
76
79
  color: var(--color-text);
77
80
  background: var(--color-surface-hover);
78
81
  outline: none;
@@ -92,7 +95,8 @@
92
95
  }
93
96
 
94
97
  .ui-context-menu__item[data-danger='true']:hover,
95
- .ui-context-menu__item[data-danger='true']:focus-visible {
98
+ .ui-context-menu__item[data-danger='true']:focus-visible,
99
+ .ui-context-menu__item[data-danger='true'][data-highlighted='true'] {
96
100
  color: #fff;
97
101
  background: var(--color-danger);
98
102
  }
@@ -20,12 +20,17 @@ export type {
20
20
  export {
21
21
  createPointerPassthroughController,
22
22
  isPointerOverHitRegion,
23
+ resolvePointerHitTarget,
23
24
  } from './pointerPassthroughRegion';
24
25
  export type {
26
+ PointerHitTestDocument,
25
27
  PointerOverHitRegionOptions,
26
28
  PointerPassthroughController,
27
29
  PointerPassthroughControllerOptions,
28
30
  PointerPassthroughPort,
29
31
  } from './pointerPassthroughRegion';
30
32
  export { usePointerPassthroughRegion } from './usePointerPassthroughRegion';
31
- export type { UsePointerPassthroughRegionOptions } from './usePointerPassthroughRegion';
33
+ export type {
34
+ PointerHitTargetResolver,
35
+ UsePointerPassthroughRegionOptions,
36
+ } from './usePointerPassthroughRegion';
@@ -1,3 +1,5 @@
1
+ import { clampNumber } from '../utils/clamp';
2
+
1
3
  export type AnchoredOverlayPlacement = 'side' | 'below' | 'above';
2
4
 
3
5
  export interface MeasureAnchoredOverlayPanelOptions {
@@ -26,10 +28,6 @@ const SIDE_MIN_VISIBLE_HEIGHT_PX = 240;
26
28
  /** Prefer below when at least this many pixels are available under the trigger. */
27
29
  const BELOW_MIN_SPACE_PX = 12;
28
30
 
29
- function clamp(value: number, min: number, max: number): number {
30
- return Math.min(Math.max(value, min), max);
31
- }
32
-
33
31
  /**
34
32
  * Measure a panel-sized overlay anchored to a trigger element.
35
33
  * Prefers a side flyout when the trigger sits in the left portion of the
@@ -65,7 +63,7 @@ export function measureAnchoredOverlayPanel(
65
63
 
66
64
  if (preferSide) {
67
65
  const minVisibleHeight = Math.min(SIDE_MIN_VISIBLE_HEIGHT_PX, maxHeight);
68
- const top = clamp(
66
+ const top = clampNumber(
69
67
  triggerRect.top,
70
68
  viewportPaddingPx,
71
69
  viewportHeight - viewportPaddingPx - minVisibleHeight,
@@ -80,7 +78,7 @@ export function measureAnchoredOverlayPanel(
80
78
  }
81
79
 
82
80
  // Align the panel's right edge toward the trigger's right, then clamp.
83
- const left = clamp(
81
+ const left = clampNumber(
84
82
  triggerRect.right - width,
85
83
  viewportPaddingPx,
86
84
  viewportWidth - viewportPaddingPx - width,
@@ -2,6 +2,22 @@ export interface PointerPassthroughPort {
2
2
  setPointerPassthrough(enabled: boolean): void | Promise<void>;
3
3
  }
4
4
 
5
+ export interface PointerHitTestDocument {
6
+ elementFromPoint(x: number, y: number): Element | null;
7
+ }
8
+
9
+ /** Resolve the painted element at finite viewport coordinates. */
10
+ export function resolvePointerHitTarget(
11
+ clientX: number,
12
+ clientY: number,
13
+ documentSurface: PointerHitTestDocument = document,
14
+ ): Element | null {
15
+ if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {
16
+ return null;
17
+ }
18
+ return documentSurface.elementFromPoint(clientX, clientY);
19
+ }
20
+
5
21
  export interface PointerOverHitRegionOptions {
6
22
  readonly hitSelectors: readonly string[];
7
23
  readonly controlSelectors?: readonly string[];
@@ -51,7 +67,7 @@ export interface PointerPassthroughController {
51
67
  /**
52
68
  * Pure controller for overlay click-through: when the pointer is outside
53
69
  * host-provided selectors, ask the port to enable passthrough.
54
- * Pair with platform `applyWindowResidency(..., 'click-through')` on the main side.
70
+ * Pair with platform `applyWindowResidencyPolicy` on the main side.
55
71
  */
56
72
  export function createPointerPassthroughController(
57
73
  options: PointerPassthroughControllerOptions,
@@ -2,9 +2,12 @@ import { useEffect, useRef, type RefObject } from 'react';
2
2
 
3
3
  import {
4
4
  createPointerPassthroughController,
5
+ resolvePointerHitTarget,
5
6
  type PointerPassthroughPort,
6
7
  } from './pointerPassthroughRegion';
7
8
 
9
+ export type PointerHitTargetResolver = (clientX: number, clientY: number) => EventTarget | null;
10
+
8
11
  export interface UsePointerPassthroughRegionOptions {
9
12
  readonly enabled: boolean;
10
13
  readonly port: PointerPassthroughPort;
@@ -14,11 +17,15 @@ export interface UsePointerPassthroughRegionOptions {
14
17
  readonly controlSelectors?: readonly string[];
15
18
  /** Limit hit-testing to a subtree; defaults to the document. */
16
19
  readonly rootRef?: RefObject<HTMLElement | null>;
20
+ /** Override painted-element resolution; defaults to `document.elementFromPoint`. */
21
+ readonly resolveHitTarget?: PointerHitTargetResolver;
17
22
  }
18
23
 
19
24
  /**
20
25
  * Toggles host pointer passthrough from renderer hit-testing.
21
- * Uses `requestAnimationFrame` coalescing for pointermove; no product selectors in kit.
26
+ * Uses `requestAnimationFrame` coalescing and painted-element hit-testing; no
27
+ * product selectors in kit. Electron can forward move events while ignoring
28
+ * mouse input, in which case `event.target` is not necessarily under the cursor.
22
29
  *
23
30
  * Host responsibilities:
24
31
  * - Provide selector lists
@@ -26,7 +33,7 @@ export interface UsePointerPassthroughRegionOptions {
26
33
  * - Pair with platform residency applicator for the main-process half
27
34
  */
28
35
  export function usePointerPassthroughRegion(options: UsePointerPassthroughRegionOptions): void {
29
- const { enabled, port, hitSelectors, controlSelectors, rootRef } = options;
36
+ const { enabled, port, hitSelectors, controlSelectors, rootRef, resolveHitTarget } = options;
30
37
 
31
38
  const portRef = useRef(port);
32
39
  portRef.current = port;
@@ -34,6 +41,8 @@ export function usePointerPassthroughRegion(options: UsePointerPassthroughRegion
34
41
  hitSelectorsRef.current = hitSelectors;
35
42
  const controlSelectorsRef = useRef(controlSelectors);
36
43
  controlSelectorsRef.current = controlSelectors;
44
+ const resolveHitTargetRef = useRef(resolveHitTarget ?? resolvePointerHitTarget);
45
+ resolveHitTargetRef.current = resolveHitTarget ?? resolvePointerHitTarget;
37
46
 
38
47
  useEffect(() => {
39
48
  const controller = createPointerPassthroughController({
@@ -58,25 +67,45 @@ export function usePointerPassthroughRegion(options: UsePointerPassthroughRegion
58
67
  }
59
68
 
60
69
  let frame = 0;
61
- let pendingTarget: EventTarget | null = null;
70
+ let pendingX = 0;
71
+ let pendingY = 0;
62
72
 
63
73
  const flush = () => {
64
74
  frame = 0;
65
- controller.handlePointerTarget(pendingTarget);
75
+ // Re-assert on every frame. Host state may also change outside this hook;
76
+ // the main-process port should make same-value updates a no-op.
77
+ controller.reset();
78
+ controller.handlePointerTarget(resolveHitTargetRef.current(pendingX, pendingY));
66
79
  };
67
80
 
68
- const onPointerMove = (event: PointerEvent) => {
69
- pendingTarget = event.target;
81
+ const onPointerMove = (event: PointerEvent | MouseEvent) => {
82
+ pendingX = event.clientX;
83
+ pendingY = event.clientY;
70
84
  if (frame !== 0) {
71
85
  return;
72
86
  }
73
87
  frame = window.requestAnimationFrame(flush);
74
88
  };
75
89
 
90
+ const onMouseLeave = () => {
91
+ if (frame !== 0) {
92
+ window.cancelAnimationFrame(frame);
93
+ frame = 0;
94
+ }
95
+ controller.reset();
96
+ controller.handlePointerTarget(null);
97
+ };
98
+
76
99
  window.addEventListener('pointermove', onPointerMove, { passive: true });
100
+ // Some Electron builds forward mousemove rather than pointermove while the
101
+ // native window ignores input.
102
+ window.addEventListener('mousemove', onPointerMove, { passive: true });
103
+ document.documentElement.addEventListener('mouseleave', onMouseLeave);
77
104
 
78
105
  return () => {
79
106
  window.removeEventListener('pointermove', onPointerMove);
107
+ window.removeEventListener('mousemove', onPointerMove);
108
+ document.documentElement.removeEventListener('mouseleave', onMouseLeave);
80
109
  if (frame !== 0) {
81
110
  window.cancelAnimationFrame(frame);
82
111
  }
@@ -9,12 +9,12 @@ export interface WorkbenchRect {
9
9
  }
10
10
 
11
11
  export interface AbsoluteBoxProps {
12
- background?: string | undefined;
12
+ background?: string;
13
13
  children?: ReactNode;
14
- className?: string | undefined;
15
- overflow?: CSSProperties['overflow'] | undefined;
14
+ className?: string;
15
+ overflow?: CSSProperties['overflow'];
16
16
  rect: WorkbenchRect;
17
- style?: CSSProperties | undefined;
17
+ style?: CSSProperties;
18
18
  }
19
19
 
20
20
  export function AbsoluteBox({
@@ -0,0 +1,33 @@
1
+ import './app-icon.css';
2
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
3
+
4
+ import { cx } from '../../utils/cx';
5
+
6
+ export type AppIconSize = 'sm' | 'md' | 'lg';
7
+
8
+ type AppIconBaseProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
9
+ size?: AppIconSize;
10
+ };
11
+
12
+ type AppIconImageProps = {
13
+ /** Accessible image text. Use an empty string when the surrounding surface already names the app. */
14
+ alt: string;
15
+ children?: never;
16
+ src: string;
17
+ };
18
+
19
+ type AppIconCustomProps = {
20
+ alt?: never;
21
+ children: ReactNode;
22
+ src?: never;
23
+ };
24
+
25
+ export type AppIconProps = AppIconBaseProps & (AppIconImageProps | AppIconCustomProps);
26
+
27
+ export function AppIcon({ alt, children, className, size = 'md', src, ...props }: AppIconProps) {
28
+ return (
29
+ <span className={cx('ui-app-icon', className)} data-size={size} {...props}>
30
+ {src === undefined ? children : <img alt={alt} className="ui-app-icon__image" src={src} />}
31
+ </span>
32
+ );
33
+ }