cli-jaw 2.17.63 → 2.17.64

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 (52) hide show
  1. package/dist/src/code-mode/store.js +35 -6
  2. package/dist/src/code-mode/store.js.map +1 -1
  3. package/dist/src/routes/code-native.js +11 -2
  4. package/dist/src/routes/code-native.js.map +1 -1
  5. package/package.json +1 -1
  6. package/public/dist/.vite/manifest.json +21 -9
  7. package/public/dist/assets/CodeCanvas-c_omHvtV.js +3 -0
  8. package/public/dist/assets/FolderPanel-DOLT_syh.js +3 -0
  9. package/public/dist/assets/{FolderPanel-BHEYcjea.css → FolderPanel-DgoqN1cX.css} +1 -1
  10. package/public/dist/assets/icons-QgChTDzZ.js +1 -0
  11. package/public/dist/assets/manager-Bq8-tr-M.js +5 -0
  12. package/public/dist/assets/manager-C6UJc985.css +1 -0
  13. package/public/dist/manager/index.html +2 -2
  14. package/public/manager/src/App.tsx +3 -3
  15. package/public/manager/src/AppChrome.tsx +1 -1
  16. package/public/manager/src/background-tasks/BackgroundTaskMonitorPanel.tsx +41 -7
  17. package/public/manager/src/background-tasks/background-task-monitor.css +1 -4
  18. package/public/manager/src/code/CodeSessionList.tsx +95 -35
  19. package/public/manager/src/code/code-controller-runtime.ts +66 -8
  20. package/public/manager/src/code/code-controller-types.ts +4 -0
  21. package/public/manager/src/code/code-session-client.ts +1 -1
  22. package/public/manager/src/code/code-session-sidebar.css +32 -0
  23. package/public/manager/src/code/code.css +2 -4
  24. package/public/manager/src/code/session-order.ts +9 -0
  25. package/public/manager/src/components/CommandBar.tsx +0 -10
  26. package/public/manager/src/components/InstanceGroups.tsx +2 -0
  27. package/public/manager/src/components/InstanceListContent.tsx +2 -0
  28. package/public/manager/src/components/InstanceRow.tsx +146 -89
  29. package/public/manager/src/components/SidebarRail.tsx +6 -5
  30. package/public/manager/src/components/ThemeSwitch.tsx +46 -22
  31. package/public/manager/src/components/context-menu/ContextMenu.tsx +207 -0
  32. package/public/manager/src/components/context-menu/context-menu.css +111 -0
  33. package/public/manager/src/components/context-menu/icons.tsx +48 -0
  34. package/public/manager/src/folder-panel/FolderContextMenu.tsx +43 -17
  35. package/public/manager/src/folder-panel/FolderPanel.tsx +9 -1
  36. package/public/manager/src/folder-panel/FolderPanelOverlays.tsx +2 -0
  37. package/public/manager/src/folder-panel/FolderTreeRows.tsx +6 -1
  38. package/public/manager/src/folder-panel/folder-panel.css +0 -33
  39. package/public/manager/src/folder-panel/use-folder-context-menu.ts +6 -19
  40. package/public/manager/src/hooks/useDashboardRegistry.ts +24 -1
  41. package/public/manager/src/jaw-ceo/JawCeoConsolePanels.tsx +71 -9
  42. package/public/manager/src/jaw-ceo/jaw-ceo-console.css +5 -7
  43. package/public/manager/src/main.tsx +2 -0
  44. package/public/manager/src/manager-components.css +1 -34
  45. package/public/manager/src/manager-desktop-titlebar.css +117 -0
  46. package/public/manager/src/manager-layout.css +0 -1
  47. package/public/manager/src/manager-p0-1-1.css +0 -42
  48. package/public/manager/src/manager-polish.css +0 -88
  49. package/public/dist/assets/CodeCanvas-CVbdp0S-.js +0 -3
  50. package/public/dist/assets/FolderPanel-CJvwRwIV.js +0 -3
  51. package/public/dist/assets/manager-CCXm5Yfe.js +0 -5
  52. package/public/dist/assets/manager-D9rhKFKc.css +0 -1
@@ -34,6 +34,7 @@ type InstanceListContentProps = {
34
34
  onPreview: (instance: DashboardInstance) => void;
35
35
  onMarkActivitySeen: (port: number) => void;
36
36
  onInstanceLabelSave: (port: number, label: string | null) => Promise<void>;
37
+ onToggleFavorite?: (instance: DashboardInstance) => void;
37
38
  onLifecycle: (action: DashboardLifecycleAction, instance: DashboardInstance) => void;
38
39
  };
39
40
 
@@ -92,6 +93,7 @@ export function InstanceListContent(props: InstanceListContentProps) {
92
93
  onPreview={props.onPreview}
93
94
  onMarkActivitySeen={props.onMarkActivitySeen}
94
95
  onInstanceLabelSave={props.onInstanceLabelSave}
96
+ {...(props.onToggleFavorite !== undefined ? { onToggleFavorite: props.onToggleFavorite } : {})}
95
97
  onLifecycle={props.onLifecycle}
96
98
  />
97
99
  )}
@@ -1,7 +1,20 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import type { FormEvent, MouseEvent } from 'react';
2
+ import type { FormEvent, KeyboardEvent, MouseEvent } from 'react';
3
3
  import type { DashboardInstance, DashboardLifecycleAction, DashboardProfile } from '../types';
4
4
  import { composeInstanceRowTitle, formatWorkingDurationLabel, resolveInstanceRowStatus } from './instance-row-status';
5
+ import { ContextMenu, useContextMenu, type ContextMenuEntry } from './context-menu/ContextMenu';
6
+ import {
7
+ ArchiveGlyph,
8
+ CopyGlyph,
9
+ ExternalGlyph,
10
+ EyeGlyph,
11
+ PencilGlyph,
12
+ PinGlyph,
13
+ PlayGlyph,
14
+ RestartGlyph,
15
+ StopGlyph,
16
+ } from './context-menu/icons';
17
+ import { copyText } from '../clipboard/copy-text';
5
18
 
6
19
  type InstanceRowProps = {
7
20
  instance: DashboardInstance;
@@ -29,6 +42,7 @@ type InstanceRowProps = {
29
42
  onPreview: (instance: DashboardInstance) => void;
30
43
  onMarkActivitySeen: (port: number) => void;
31
44
  onInstanceLabelSave: (port: number, label: string | null) => Promise<void>;
45
+ onToggleFavorite?: (instance: DashboardInstance) => void;
32
46
  onLifecycle: (action: DashboardLifecycleAction, instance: DashboardInstance) => void;
33
47
  jumpHint?: string | null;
34
48
  };
@@ -45,20 +59,6 @@ function statusClass(status: DashboardInstance['status']): string {
45
59
  return `instance-status status-${status}`;
46
60
  }
47
61
 
48
- const StopIcon = () => (
49
- <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true">
50
- <rect x="3" y="3" width="10" height="10" rx="1.5" />
51
- </svg>
52
- );
53
-
54
- const OpenIcon = () => (
55
- <svg viewBox="0 0 16 16" width="12" height="12" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
56
- <path d="M6.5 3.5H4a1 1 0 0 0-1 1V12a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V9.5" />
57
- <path d="M9.5 2.5h4v4" />
58
- <path d="M13.5 2.5 8 8" />
59
- </svg>
60
- );
61
-
62
62
  const ChevronIcon = ({ open }: { open: boolean }) => (
63
63
  <svg viewBox="0 0 16 16" width="12" height="12" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
64
64
  style={{ transform: open ? 'rotate(180deg)' : undefined, transition: 'transform 0.12s' }}>
@@ -88,6 +88,8 @@ export function InstanceRow(props: InstanceRowProps) {
88
88
  const [draft, setDraft] = useState(props.instance.label || props.profile?.label || props.label);
89
89
  const [savingLabel, setSavingLabel] = useState(false);
90
90
  const [labelError, setLabelError] = useState<string | null>(null);
91
+ const rowMenu = useContextMenu();
92
+ const labelInputRef = useRef<HTMLInputElement | null>(null);
91
93
 
92
94
  function stopAction(event: MouseEvent<HTMLElement>): void {
93
95
  event.stopPropagation();
@@ -113,6 +115,118 @@ export function InstanceRow(props: InstanceRowProps) {
113
115
  const hideStatusLine = props.density === 'compact' || props.density === 'rail';
114
116
  const dotClass = `${statusClass(props.instance.status)}${transitionLabel ? ' is-transitioning' : ''}${props.agentBusy ? ' is-busy' : ''} is-${rowStatus}`;
115
117
  const primaryLabel = props.instance.label || props.profile?.label || props.label;
118
+
119
+ useEffect(() => {
120
+ if (editing) labelInputRef.current?.focus();
121
+ }, [editing]);
122
+
123
+ function startEditing(): void {
124
+ setDraft(props.instance.label || props.profile?.label || props.label);
125
+ setLabelError(null);
126
+ setEditing(true);
127
+ }
128
+
129
+ function openMenuFromKey(event: KeyboardEvent<HTMLElement>): void {
130
+ if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) {
131
+ const target = event.target;
132
+ if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable]')) return;
133
+ rowMenu.openAt(event);
134
+ }
135
+ }
136
+
137
+ function menuEntries(): ContextMenuEntry[] {
138
+ const entries: ContextMenuEntry[] = [];
139
+ if (props.showInlineLabelEditor !== false) {
140
+ entries.push({
141
+ id: 'rename',
142
+ label: 'Rename',
143
+ icon: <PencilGlyph />,
144
+ onSelect: startEditing,
145
+ });
146
+ }
147
+ if (props.onToggleFavorite) {
148
+ entries.push({
149
+ id: 'pin',
150
+ label: props.instance.favorite ? 'Unpin' : 'Pin',
151
+ icon: <PinGlyph />,
152
+ onSelect: () => props.onToggleFavorite?.(props.instance),
153
+ });
154
+ }
155
+ if (entries.length) entries.push({ kind: 'separator', id: 'sep-edit' });
156
+ if (props.showSelectedActions !== false) {
157
+ entries.push({
158
+ id: 'preview',
159
+ label: 'Preview',
160
+ icon: <EyeGlyph />,
161
+ disabled: !props.instance.ok,
162
+ onSelect: () => props.onPreview(props.instance),
163
+ });
164
+ }
165
+ entries.push({
166
+ id: 'open',
167
+ label: 'Open in new tab',
168
+ icon: <ExternalGlyph />,
169
+ disabled: !props.instance.ok,
170
+ onSelect: () => {
171
+ props.onMarkActivitySeen(props.instance.port);
172
+ window.open(props.instance.url, '_blank', 'noreferrer');
173
+ },
174
+ });
175
+ entries.push({ kind: 'separator', id: 'sep-lifecycle' });
176
+ if (props.showSelectedActions !== false) {
177
+ const startTitle = lifecycle?.commandPreview?.join(' ');
178
+ entries.push(
179
+ {
180
+ id: 'start',
181
+ label: 'Start',
182
+ icon: <PlayGlyph />,
183
+ disabled: !lifecycle?.canStart || props.busy,
184
+ ...(startTitle ? { title: startTitle } : {}),
185
+ onSelect: () => props.onLifecycle('start', props.instance),
186
+ },
187
+ {
188
+ id: 'restart',
189
+ label: 'Restart',
190
+ icon: <RestartGlyph />,
191
+ disabled: !lifecycle?.canRestart || props.busy,
192
+ onSelect: () => props.onLifecycle('restart', props.instance),
193
+ },
194
+ {
195
+ id: 'perm',
196
+ label: 'Register as persistent service',
197
+ icon: <ArchiveGlyph />,
198
+ disabled: !lifecycle?.canPerm || props.busy,
199
+ onSelect: () => props.onLifecycle('perm', props.instance),
200
+ },
201
+ );
202
+ }
203
+ entries.push({
204
+ id: 'stop',
205
+ label: 'Stop',
206
+ icon: <StopGlyph />,
207
+ danger: true,
208
+ disabled: !lifecycle?.canStop || props.busy,
209
+ onSelect: () => props.onLifecycle('stop', props.instance),
210
+ });
211
+ entries.push({ kind: 'separator', id: 'sep-copy' });
212
+ entries.push(
213
+ {
214
+ id: 'copy-url',
215
+ label: 'Copy URL',
216
+ icon: <CopyGlyph />,
217
+ disabled: !props.instance.ok,
218
+ onSelect: () => void copyText(props.instance.url),
219
+ },
220
+ {
221
+ id: 'copy-port',
222
+ label: 'Copy port',
223
+ icon: <CopyGlyph />,
224
+ onSelect: () => void copyText(String(props.instance.port)),
225
+ },
226
+ );
227
+ return entries;
228
+ }
229
+
116
230
  async function submitLabel(event: FormEvent<HTMLFormElement>): Promise<void> {
117
231
  event.preventDefault();
118
232
  event.stopPropagation();
@@ -133,6 +247,8 @@ export function InstanceRow(props: InstanceRowProps) {
133
247
  className={`instance-row density-${props.density || 'comfortable'} priority-${props.priority || 'normal'} ${props.selected ? 'is-selected' : ''}${transitionLabel ? ' is-transitioning-row' : ''} is-${rowStatus}`}
134
248
  title={composeInstanceRowTitle(props.instance)}
135
249
  aria-current={props.selected ? 'true' : undefined}
250
+ onContextMenu={rowMenu.openAt}
251
+ onKeyDown={openMenuFromKey}
136
252
  >
137
253
  <div className="instance-row-body">
138
254
  <button
@@ -201,7 +317,7 @@ export function InstanceRow(props: InstanceRowProps) {
201
317
  title="Stop"
202
318
  aria-label="Stop"
203
319
  >
204
- <StopIcon />
320
+ <StopGlyph />
205
321
  </button>
206
322
  <a
207
323
  className={`quick-btn action-open${!props.instance.ok ? ' is-disabled' : ''}`}
@@ -218,14 +334,20 @@ export function InstanceRow(props: InstanceRowProps) {
218
334
  props.onMarkActivitySeen(props.instance.port);
219
335
  }}
220
336
  >
221
- <OpenIcon />
337
+ <ExternalGlyph />
222
338
  </a>
223
339
  <span className="port">:{props.instance.port}</span>
224
340
  </div>
225
341
  </div>
226
342
  {props.showInlineLabelEditor !== false && editing ? (
227
- <form className="instance-label-edit-form" onSubmit={(event) => void submitLabel(event)} onClick={stopAction}>
343
+ <form
344
+ className="instance-label-edit-form"
345
+ onSubmit={(event) => void submitLabel(event)}
346
+ onClick={stopAction}
347
+ onContextMenu={event => event.stopPropagation()}
348
+ >
228
349
  <input
350
+ ref={labelInputRef}
229
351
  className="instance-label-input"
230
352
  value={draft}
231
353
  maxLength={120}
@@ -247,79 +369,14 @@ export function InstanceRow(props: InstanceRowProps) {
247
369
  </button>
248
370
  {labelError && <span className="instance-label-error">{labelError}</span>}
249
371
  </form>
250
- ) : props.showInlineLabelEditor !== false ? (
251
- <button
252
- className="instance-label-edit-button"
253
- type="button"
254
- aria-label={`Rename ${primaryLabel}`}
255
- title="Rename"
256
- onClick={(event) => {
257
- stopAction(event);
258
- setDraft(props.instance.label || props.profile?.label || props.label);
259
- setLabelError(null);
260
- setEditing(true);
261
- }}
262
- >
263
- <svg viewBox="0 0 24 24" width="13" height="13" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
264
- <path d="M12 20h9" />
265
- <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
266
- </svg>
267
- </button>
268
372
  ) : null}
269
373
  {props.jumpHint ? <span className="instance-jump-hint" aria-hidden="true">{props.jumpHint}</span> : null}
270
- {props.showSelectedActions !== false && (
271
- <div className="instance-actions">
272
- <button
273
- type="button"
274
- aria-label="Preview"
275
- title="Preview"
276
- onClick={(event) => {
277
- stopAction(event);
278
- props.onPreview(props.instance);
279
- }}
280
- disabled={!props.instance.ok}
281
- >
282
- Prev
283
- </button>
284
- <button
285
- type="button"
286
- className="action-start"
287
- aria-label="Start"
288
- onClick={(event) => {
289
- stopAction(event);
290
- props.onLifecycle('start', props.instance);
291
- }}
292
- disabled={!lifecycle?.canStart || props.busy}
293
- title={lifecycle?.commandPreview?.join(' ')}
294
- >
295
- Start
296
- </button>
297
- <button
298
- type="button"
299
- aria-label="Register as persistent service"
300
- onClick={(event) => {
301
- stopAction(event);
302
- props.onLifecycle('perm', props.instance);
303
- }}
304
- disabled={!lifecycle?.canPerm || props.busy}
305
- title="Register as persistent service"
306
- >
307
- Perm
308
- </button>
309
- <button
310
- type="button"
311
- aria-label="Restart"
312
- title="Restart"
313
- onClick={(event) => {
314
- stopAction(event);
315
- props.onLifecycle('restart', props.instance);
316
- }}
317
- disabled={!lifecycle?.canRestart || props.busy}
318
- >
319
- Res
320
- </button>
321
- </div>
322
- )}
374
+ <ContextMenu
375
+ state={rowMenu.state}
376
+ entries={menuEntries()}
377
+ label={`${primaryLabel} actions`}
378
+ onClose={rowMenu.close}
379
+ />
323
380
  </article>
324
381
  );
325
382
  }
@@ -12,12 +12,11 @@ type SidebarRailProps = {
12
12
  onToggleHelp: () => void;
13
13
  };
14
14
 
15
- function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
16
- const points = direction === 'left' ? '11 4 5 10 11 16' : '5 4 11 10 5 16';
15
+ function SidebarToggleIcon({ expanded }: { expanded: boolean }) {
17
16
  return (
18
17
  <svg
19
18
  className="rail-collapse-chevron"
20
- viewBox="0 0 16 20"
19
+ viewBox="0 0 20 20"
21
20
  width="18"
22
21
  height="18"
23
22
  fill="none"
@@ -28,7 +27,9 @@ function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
28
27
  aria-hidden="true"
29
28
  focusable="false"
30
29
  >
31
- <polyline points={points} />
30
+ <rect x="3" y="4" width="14" height="12" rx="2.5" />
31
+ <path d="M8 4v12" />
32
+ {expanded && <rect x="4.5" y="5.5" width="2" height="9" rx="0.5" fill="currentColor" stroke="none" />}
32
33
  </svg>
33
34
  );
34
35
  }
@@ -112,7 +113,7 @@ export function SidebarRail(props: SidebarRailProps) {
112
113
  aria-controls="manager-sidebar-list"
113
114
  title={toggleLabel}
114
115
  >
115
- <ChevronIcon direction={expanded ? 'left' : 'right'} />
116
+ <SidebarToggleIcon expanded={expanded} />
116
117
  </button>
117
118
  <button
118
119
  className={`rail-button rail-workspace-button${props.mode === 'instances' ? ' is-active' : ''}`}
@@ -1,35 +1,59 @@
1
+ import { useRef, type ReactNode } from 'react';
1
2
  import type { DashboardUiTheme } from '../types';
3
+ import { ContextMenu, useContextMenu, type ContextMenuEntry } from './context-menu/ContextMenu';
4
+ import { CheckGlyph } from './context-menu/icons';
2
5
 
3
6
  type ThemeSwitchProps = {
4
7
  theme: DashboardUiTheme;
5
8
  onChange: (next: DashboardUiTheme) => void;
6
9
  };
7
10
 
8
- const OPTIONS: ReadonlyArray<{ value: DashboardUiTheme; label: string; hint: string }> = [
9
- { value: 'auto', label: 'Auto', hint: 'Follow OS preference' },
10
- { value: 'light', label: 'Light', hint: 'Always light theme' },
11
- { value: 'dark', label: 'Dark', hint: 'Always dark theme' },
11
+ const glyph = {
12
+ viewBox: '0 0 20 20', width: 16, height: 16, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6,
13
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false,
14
+ } as const;
15
+
16
+ const OPTIONS: ReadonlyArray<{ value: DashboardUiTheme; label: string; hint: string; icon: ReactNode }> = [
17
+ { value: 'auto', label: 'Auto', hint: 'Follow OS preference', icon: <svg {...glyph}><circle cx="10" cy="10" r="6.5" /><path d="M10 3.5v13a6.5 6.5 0 0 0 0-13Z" fill="currentColor" stroke="none" /></svg> },
18
+ { value: 'light', label: 'Light', hint: 'Always light theme', icon: <svg {...glyph}><circle cx="10" cy="10" r="3.2" /><path d="M10 2.5v1.8M10 15.7v1.8M2.5 10h1.8M15.7 10h1.8M4.7 4.7l1.3 1.3M14 14l1.3 1.3M4.7 15.3 6 14M14 6l1.3-1.3" /></svg> },
19
+ { value: 'dark', label: 'Dark', hint: 'Always dark theme', icon: <svg {...glyph}><path d="M16 12.2A6.5 6.5 0 0 1 7.8 4a6.5 6.5 0 1 0 8.2 8.2Z" /></svg> },
12
20
  ];
13
21
 
14
22
  export function ThemeSwitch(props: ThemeSwitchProps) {
23
+ const trigger = useRef<HTMLButtonElement>(null);
24
+ const menu = useContextMenu();
25
+ const closedAt = useRef(0);
26
+ const suppressClick = useRef(false);
27
+ const closeMenu = () => { closedAt.current = performance.now(); menu.close(); };
28
+ const current = OPTIONS.find(option => option.value === props.theme) ?? OPTIONS[0]!;
29
+ const entries: ContextMenuEntry[] = OPTIONS.map(option => ({
30
+ id: option.value,
31
+ label: option.label,
32
+ title: option.hint,
33
+ icon: option.value === props.theme ? <CheckGlyph /> : undefined,
34
+ checked: option.value === props.theme,
35
+ onSelect: () => props.onChange(option.value),
36
+ }));
15
37
  return (
16
- <div className="theme-switch" role="radiogroup" aria-label="Theme">
17
- {OPTIONS.map(option => {
18
- const active = props.theme === option.value;
19
- return (
20
- <button
21
- key={option.value}
22
- type="button"
23
- role="radio"
24
- aria-checked={active}
25
- title={option.hint}
26
- className={active ? 'is-active' : ''}
27
- onClick={() => props.onChange(option.value)}
28
- >
29
- {option.label}
30
- </button>
31
- );
32
- })}
33
- </div>
38
+ <>
39
+ <button
40
+ ref={trigger}
41
+ type="button"
42
+ className={`command-icon-button theme-switch${menu.open ? ' is-open' : ''}`}
43
+ aria-label={`Theme: ${current.label}`}
44
+ aria-haspopup="menu"
45
+ aria-expanded={menu.open}
46
+ title={`Theme: ${current.label}`}
47
+ onPointerDown={() => { suppressClick.current = performance.now() - closedAt.current < 50; }}
48
+ onClick={() => {
49
+ if (menu.open) { menu.close(); return; }
50
+ if (suppressClick.current) { suppressClick.current = false; return; }
51
+ if (trigger.current) menu.openAtElement(trigger.current);
52
+ }}
53
+ >
54
+ {current.icon}
55
+ </button>
56
+ <ContextMenu state={menu.state} entries={entries} label="Theme" onClose={closeMenu} className="theme-switch-menu" />
57
+ </>
34
58
  );
35
59
  }
@@ -0,0 +1,207 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ export type ContextMenuAction = {
5
+ kind?: 'action';
6
+ id: string;
7
+ label: string;
8
+ icon?: ReactNode;
9
+ /** Display-only hint, e.g. formatShortcut(...). */
10
+ shortcut?: string;
11
+ disabled?: boolean;
12
+ danger?: boolean;
13
+ /** When set, the item renders as a `menuitemradio` with this checked state. */
14
+ checked?: boolean;
15
+ title?: string;
16
+ onSelect: () => void;
17
+ };
18
+ export type ContextMenuSeparator = { kind: 'separator'; id: string };
19
+ export type ContextMenuEntry = ContextMenuAction | ContextMenuSeparator;
20
+
21
+ export type ContextMenuState = { x: number; y: number; opener?: Element | null };
22
+
23
+ const VIEWPORT_MARGIN = 8;
24
+
25
+ function isAction(entry: ContextMenuEntry): entry is ContextMenuAction {
26
+ return entry.kind !== 'separator';
27
+ }
28
+
29
+ function isFocusable(element: HTMLElement): boolean {
30
+ if (element.matches(':disabled') || element.closest('[hidden], [inert]')) return false;
31
+ for (let node: HTMLElement | null = element; node; node = node.parentElement) {
32
+ const style = window.getComputedStyle(node);
33
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false;
34
+ }
35
+ return element.tabIndex >= 0 || element.hasAttribute('tabindex') || element.isContentEditable;
36
+ }
37
+
38
+ function isTabbable(element: HTMLElement): boolean {
39
+ return element.tabIndex >= 0 && isFocusable(element);
40
+ }
41
+
42
+ function nextTabbable(opener: Element | null, menu: HTMLElement | null, direction: 1 | -1): HTMLElement | null {
43
+ if (!(opener instanceof HTMLElement)) return null;
44
+ const tabbables = Array.from(document.querySelectorAll<HTMLElement>('*'))
45
+ .filter(element => !menu?.contains(element) && isTabbable(element));
46
+ const index = tabbables.indexOf(opener);
47
+ if (index < 0 || tabbables.length < 2) return null;
48
+ return tabbables[(index + direction + tabbables.length) % tabbables.length] ?? null;
49
+ }
50
+
51
+ /**
52
+ * Tracks one open menu. `openAt` takes the right-click (or keyboard) event and
53
+ * anchors the menu at the pointer, or at the target's bottom-left for keyboard
54
+ * invocations that report 0,0.
55
+ */
56
+ export function useContextMenu() {
57
+ const [state, setState] = useState<ContextMenuState | null>(null);
58
+ const close = useCallback(() => setState(null), []);
59
+ const openAt = useCallback((event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
60
+ event.preventDefault();
61
+ event.stopPropagation();
62
+ const target = event.currentTarget;
63
+ const active = document.activeElement;
64
+ const opener = active && target.contains(active) ? active
65
+ : isFocusable(target) ? target
66
+ : Array.from(target.querySelectorAll<HTMLElement>('*')).find(isFocusable) ?? active;
67
+ if ('clientX' in event && (event.clientX !== 0 || event.clientY !== 0)) {
68
+ setState({ x: event.clientX, y: event.clientY, opener });
69
+ return;
70
+ }
71
+ const rect = target.getBoundingClientRect();
72
+ setState({ x: rect.left + 8, y: rect.bottom, opener });
73
+ }, []);
74
+ const openAtElement = useCallback((element: HTMLElement) => {
75
+ const rect = element.getBoundingClientRect();
76
+ setState({ x: rect.left, y: rect.bottom + 2, opener: element });
77
+ }, []);
78
+ return { state, open: state !== null, openAt, openAtElement, close };
79
+ }
80
+
81
+ export function ContextMenu({ state, entries, label, onClose, className }: {
82
+ state: ContextMenuState | null;
83
+ entries: ContextMenuEntry[];
84
+ label: string;
85
+ onClose: () => void;
86
+ className?: string;
87
+ }) {
88
+ const menu = useRef<HTMLDivElement>(null);
89
+ /** Dismissal hands focus back to the captured opener. */
90
+ const restoreOnClose = useRef(true);
91
+ const pendingRestoreFrame = useRef<number | null>(null);
92
+ const cancelPendingRestore = useCallback(() => {
93
+ if (pendingRestoreFrame.current === null) return;
94
+ window.cancelAnimationFrame(pendingRestoreFrame.current);
95
+ pendingRestoreFrame.current = null;
96
+ }, []);
97
+ const [position, setPosition] = useState<ContextMenuState | null>(null);
98
+ const onCloseRef = useRef(onClose);
99
+ onCloseRef.current = onClose;
100
+ const anchorX = state?.x;
101
+ const anchorY = state?.y;
102
+ const opener = state?.opener;
103
+
104
+ useEffect(() => cancelPendingRestore, [cancelPendingRestore]);
105
+
106
+ useLayoutEffect(() => {
107
+ if (anchorX === undefined || anchorY === undefined) { setPosition(null); return; }
108
+ const node = menu.current;
109
+ const width = node?.offsetWidth ?? 0;
110
+ const height = node?.offsetHeight ?? 0;
111
+ const maxX = window.innerWidth - width - VIEWPORT_MARGIN;
112
+ const maxY = window.innerHeight - height - VIEWPORT_MARGIN;
113
+ setPosition({
114
+ x: Math.max(VIEWPORT_MARGIN, Math.min(anchorX, maxX)),
115
+ y: Math.max(VIEWPORT_MARGIN, Math.min(anchorY > maxY ? anchorY - height : anchorY, maxY)),
116
+ });
117
+ }, [anchorX, anchorY]);
118
+
119
+ useEffect(() => {
120
+ if (anchorX === undefined || anchorY === undefined) return;
121
+ cancelPendingRestore();
122
+ const onClose = () => onCloseRef.current();
123
+ restoreOnClose.current = true;
124
+ const first = menu.current?.querySelector<HTMLButtonElement>(':is([role="menuitem"], [role="menuitemradio"]):not(:disabled)');
125
+ first?.focus({ preventScroll: true });
126
+ const onPointerDown = (event: PointerEvent) => {
127
+ if (menu.current && event.target instanceof Node && menu.current.contains(event.target)) return;
128
+ onClose();
129
+ };
130
+ const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); onClose(); } };
131
+ const onDismiss = () => onClose();
132
+ const onScroll = (event: Event) => {
133
+ if (menu.current && event.target instanceof Node && menu.current.contains(event.target)) return;
134
+ onClose();
135
+ };
136
+ window.addEventListener('pointerdown', onPointerDown, true);
137
+ window.addEventListener('keydown', onKey);
138
+ window.addEventListener('blur', onDismiss);
139
+ window.addEventListener('resize', onDismiss);
140
+ window.addEventListener('scroll', onScroll, true);
141
+ return () => {
142
+ window.removeEventListener('pointerdown', onPointerDown, true);
143
+ window.removeEventListener('keydown', onKey);
144
+ window.removeEventListener('blur', onDismiss);
145
+ window.removeEventListener('resize', onDismiss);
146
+ window.removeEventListener('scroll', onScroll, true);
147
+ if (restoreOnClose.current && opener instanceof HTMLElement && opener.isConnected) opener.focus({ preventScroll: true });
148
+ };
149
+ }, [anchorX, anchorY, opener, cancelPendingRestore]);
150
+
151
+ if (!state) return null;
152
+
153
+ function moveFocus(step: 1 | -1 | 'first' | 'last') {
154
+ const items = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>(':is([role="menuitem"], [role="menuitemradio"]):not(:disabled)') ?? []);
155
+ if (!items.length) return;
156
+ const index = items.indexOf(document.activeElement as HTMLButtonElement);
157
+ const next = step === 'first' ? 0 : step === 'last' ? items.length - 1
158
+ : (index + step + items.length) % items.length;
159
+ items[next]?.focus({ preventScroll: true });
160
+ }
161
+
162
+ function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
163
+ const moves: Record<string, 1 | -1 | 'first' | 'last'> = { ArrowDown: 1, ArrowUp: -1, Home: 'first', End: 'last' };
164
+ const move = moves[event.key];
165
+ if (move !== undefined) { event.preventDefault(); moveFocus(move); }
166
+ else if (event.key === 'Escape') { event.preventDefault(); onClose(); }
167
+ else if (event.key === 'Tab') {
168
+ event.preventDefault();
169
+ const next = nextTabbable(opener ?? null, menu.current, event.shiftKey ? -1 : 1);
170
+ restoreOnClose.current = false;
171
+ onClose();
172
+ next?.focus({ preventScroll: true });
173
+ }
174
+ event.stopPropagation();
175
+ }
176
+
177
+ const shown = position ?? state;
178
+ return createPortal(<div ref={menu} className={`jaw-context-menu${className ? ` ${className}` : ''}`} role="menu" aria-label={label}
179
+ style={{ left: shown.x, top: shown.y, visibility: position ? 'visible' : 'hidden' }}
180
+ onKeyDown={onKeyDown} onContextMenu={event => event.preventDefault()}>
181
+ {entries.map(entry => isAction(entry)
182
+ ? <button key={entry.id} type="button" role={entry.checked === undefined ? 'menuitem' : 'menuitemradio'}
183
+ aria-checked={entry.checked} disabled={entry.disabled} title={entry.title}
184
+ className={`jaw-context-menu-item${entry.danger ? ' is-danger' : ''}`}
185
+ onClick={() => {
186
+ const previousMenu = menu.current;
187
+ const previousOpener = state.opener;
188
+ restoreOnClose.current = false;
189
+ onClose();
190
+ entry.onSelect();
191
+ cancelPendingRestore();
192
+ pendingRestoreFrame.current = window.requestAnimationFrame(() => {
193
+ pendingRestoreFrame.current = null;
194
+ const active = document.activeElement;
195
+ if ((!active || active === document.body || !active.isConnected || (previousMenu?.contains(active) ?? false))
196
+ && previousOpener instanceof HTMLElement && previousOpener.isConnected) {
197
+ previousOpener.focus({ preventScroll: true });
198
+ }
199
+ });
200
+ }}>
201
+ <span className="jaw-context-menu-icon" aria-hidden="true">{entry.icon}</span>
202
+ <span className="jaw-context-menu-label">{entry.label}</span>
203
+ {entry.shortcut && <kbd className="jaw-context-menu-shortcut" aria-hidden="true">{entry.shortcut}</kbd>}
204
+ </button>
205
+ : <div key={entry.id} className="jaw-context-menu-separator" role="separator" />)}
206
+ </div>, document.body);
207
+ }