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.
- package/dist/src/code-mode/store.js +35 -6
- package/dist/src/code-mode/store.js.map +1 -1
- package/dist/src/routes/code-native.js +11 -2
- package/dist/src/routes/code-native.js.map +1 -1
- package/package.json +1 -1
- package/public/dist/.vite/manifest.json +21 -9
- package/public/dist/assets/CodeCanvas-c_omHvtV.js +3 -0
- package/public/dist/assets/FolderPanel-DOLT_syh.js +3 -0
- package/public/dist/assets/{FolderPanel-BHEYcjea.css → FolderPanel-DgoqN1cX.css} +1 -1
- package/public/dist/assets/icons-QgChTDzZ.js +1 -0
- package/public/dist/assets/manager-Bq8-tr-M.js +5 -0
- package/public/dist/assets/manager-C6UJc985.css +1 -0
- package/public/dist/manager/index.html +2 -2
- package/public/manager/src/App.tsx +3 -3
- package/public/manager/src/AppChrome.tsx +1 -1
- package/public/manager/src/background-tasks/BackgroundTaskMonitorPanel.tsx +41 -7
- package/public/manager/src/background-tasks/background-task-monitor.css +1 -4
- package/public/manager/src/code/CodeSessionList.tsx +95 -35
- package/public/manager/src/code/code-controller-runtime.ts +66 -8
- package/public/manager/src/code/code-controller-types.ts +4 -0
- package/public/manager/src/code/code-session-client.ts +1 -1
- package/public/manager/src/code/code-session-sidebar.css +32 -0
- package/public/manager/src/code/code.css +2 -4
- package/public/manager/src/code/session-order.ts +9 -0
- package/public/manager/src/components/CommandBar.tsx +0 -10
- package/public/manager/src/components/InstanceGroups.tsx +2 -0
- package/public/manager/src/components/InstanceListContent.tsx +2 -0
- package/public/manager/src/components/InstanceRow.tsx +146 -89
- package/public/manager/src/components/SidebarRail.tsx +6 -5
- package/public/manager/src/components/ThemeSwitch.tsx +46 -22
- package/public/manager/src/components/context-menu/ContextMenu.tsx +207 -0
- package/public/manager/src/components/context-menu/context-menu.css +111 -0
- package/public/manager/src/components/context-menu/icons.tsx +48 -0
- package/public/manager/src/folder-panel/FolderContextMenu.tsx +43 -17
- package/public/manager/src/folder-panel/FolderPanel.tsx +9 -1
- package/public/manager/src/folder-panel/FolderPanelOverlays.tsx +2 -0
- package/public/manager/src/folder-panel/FolderTreeRows.tsx +6 -1
- package/public/manager/src/folder-panel/folder-panel.css +0 -33
- package/public/manager/src/folder-panel/use-folder-context-menu.ts +6 -19
- package/public/manager/src/hooks/useDashboardRegistry.ts +24 -1
- package/public/manager/src/jaw-ceo/JawCeoConsolePanels.tsx +71 -9
- package/public/manager/src/jaw-ceo/jaw-ceo-console.css +5 -7
- package/public/manager/src/main.tsx +2 -0
- package/public/manager/src/manager-components.css +1 -34
- package/public/manager/src/manager-desktop-titlebar.css +117 -0
- package/public/manager/src/manager-layout.css +0 -1
- package/public/manager/src/manager-p0-1-1.css +0 -42
- package/public/manager/src/manager-polish.css +0 -88
- package/public/dist/assets/CodeCanvas-CVbdp0S-.js +0 -3
- package/public/dist/assets/FolderPanel-CJvwRwIV.js +0 -3
- package/public/dist/assets/manager-CCXm5Yfe.js +0 -5
- 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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
}
|