@hoox-sh/hoox-tui 0.3.0 → 0.3.2
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/main.js +597 -327
- package/package.json +3 -5
- package/src/app.tsx +7 -9
- package/src/components/ui/dialog-state.ts +15 -0
- package/src/components/ui/dialog.tsx +447 -172
- package/src/components/ui/select.tsx +10 -158
- package/src/components/ui/toast.tsx +44 -92
- package/src/components/views/service-manager.test.tsx +8 -27
- package/src/hooks/shell-overlay.ts +3 -2
- package/src/main.tsx +5 -14
|
@@ -5,20 +5,27 @@
|
|
|
5
5
|
|
|
6
6
|
/** @jsxImportSource @opentui/react */
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
* Provides ConfirmDialog, ChoiceDialog, and LoadingDialog helpers.
|
|
8
|
+
* In-house dialog system for Hoox TUI.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Intentionally does **not** depend on `@opentui-ui/dialog`, which nests a
|
|
11
|
+
* second `@opentui/core` and crashes global installs with:
|
|
12
|
+
* OPENTUI_FORCE_WCWIDTH already registered with different configuration
|
|
13
|
+
*
|
|
14
|
+
* Provides DialogProvider + useDialog + showConfirm / showChoice / showLoading
|
|
15
|
+
* with the same call shapes views already use.
|
|
13
16
|
*/
|
|
14
|
-
import { useState } from "react";
|
|
15
17
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
createContext,
|
|
19
|
+
useCallback,
|
|
20
|
+
useContext,
|
|
21
|
+
useEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useState,
|
|
24
|
+
type ReactNode,
|
|
25
|
+
} from "react";
|
|
26
|
+
import { useKeyboard } from "@opentui/react";
|
|
21
27
|
import { Colors } from "@hoox-sh/hoox-shared";
|
|
28
|
+
import { setOpenDialogCount } from "./dialog-state";
|
|
22
29
|
|
|
23
30
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
24
31
|
|
|
@@ -55,227 +62,495 @@ export interface ChoiceDialogOptions<K extends string = string> {
|
|
|
55
62
|
closeOnClickOutside?: boolean;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
/** Dialog manager interface
|
|
65
|
+
/** Dialog manager interface used by views + showConfirm helpers */
|
|
59
66
|
export interface DialogHandle {
|
|
67
|
+
/**
|
|
68
|
+
* Low-level confirm used by some tests. Prefer {@link showConfirm}.
|
|
69
|
+
* When `content` is provided, it is ignored — title/message form is used.
|
|
70
|
+
*/
|
|
60
71
|
confirm(options: {
|
|
61
|
-
content
|
|
72
|
+
content?: (ctx: {
|
|
73
|
+
resolve: (value: boolean) => void;
|
|
74
|
+
dismiss: () => void;
|
|
75
|
+
}) => unknown;
|
|
76
|
+
title?: string;
|
|
77
|
+
message?: string;
|
|
78
|
+
confirmLabel?: string;
|
|
79
|
+
cancelLabel?: string;
|
|
62
80
|
closeOnClickOutside?: boolean;
|
|
63
81
|
}): Promise<boolean>;
|
|
64
82
|
choice<K extends string>(options: {
|
|
65
|
-
content
|
|
83
|
+
content?: (ctx: {
|
|
84
|
+
resolve: (value: K) => void;
|
|
85
|
+
dismiss: () => void;
|
|
86
|
+
dialogId: string | number;
|
|
87
|
+
}) => unknown;
|
|
88
|
+
title?: string;
|
|
89
|
+
choices?: ChoiceOption<K>[];
|
|
66
90
|
fallback?: K;
|
|
67
91
|
closeOnClickOutside?: boolean;
|
|
68
92
|
}): Promise<K | undefined>;
|
|
69
93
|
show(options: {
|
|
70
|
-
content
|
|
94
|
+
content?: () => unknown;
|
|
95
|
+
message?: string;
|
|
71
96
|
id?: string | number;
|
|
72
97
|
}): string | number;
|
|
73
98
|
close(id?: string | number): void;
|
|
99
|
+
/** High-level helpers (also used directly by showConfirm) */
|
|
100
|
+
confirmSimple(options: ConfirmDialogOptions): Promise<boolean>;
|
|
101
|
+
choiceSimple<K extends string>(
|
|
102
|
+
options: ChoiceDialogOptions<K>
|
|
103
|
+
): Promise<K | undefined>;
|
|
74
104
|
}
|
|
75
105
|
|
|
76
|
-
// ── Internal
|
|
106
|
+
// ── Internal stack model ───────────────────────────────────────────────────
|
|
77
107
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
type StackEntry =
|
|
109
|
+
| {
|
|
110
|
+
kind: "confirm";
|
|
111
|
+
id: number;
|
|
112
|
+
title: string;
|
|
113
|
+
message: string;
|
|
114
|
+
confirmLabel: string;
|
|
115
|
+
cancelLabel: string;
|
|
116
|
+
closeOnClickOutside: boolean;
|
|
117
|
+
resolve: (value: boolean) => void;
|
|
118
|
+
}
|
|
119
|
+
| {
|
|
120
|
+
kind: "choice";
|
|
121
|
+
id: number;
|
|
122
|
+
title: string;
|
|
123
|
+
choices: ChoiceOption[];
|
|
124
|
+
fallback?: string;
|
|
125
|
+
closeOnClickOutside: boolean;
|
|
126
|
+
resolve: (value: string | undefined) => void;
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
kind: "loading";
|
|
130
|
+
id: number;
|
|
131
|
+
message: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
let nextId = 1;
|
|
135
|
+
|
|
136
|
+
const DialogCtx = createContext<DialogHandle | null>(null);
|
|
137
|
+
|
|
138
|
+
// ── Overlay UI ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function ConfirmOverlay({
|
|
141
|
+
entry,
|
|
142
|
+
onResolve,
|
|
143
|
+
}: {
|
|
144
|
+
entry: Extract<StackEntry, { kind: "confirm" }>;
|
|
145
|
+
onResolve: (value: boolean) => void;
|
|
146
|
+
}) {
|
|
147
|
+
useKeyboard((key) => {
|
|
148
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
149
|
+
if (name === "return" || name === "enter" || name === "y") {
|
|
150
|
+
onResolve(true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (name === "escape" || name === "n") {
|
|
154
|
+
onResolve(false);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<box
|
|
160
|
+
position="absolute"
|
|
161
|
+
left={0}
|
|
162
|
+
top={0}
|
|
163
|
+
width="100%"
|
|
164
|
+
height="100%"
|
|
165
|
+
justifyContent="center"
|
|
166
|
+
alignItems="center"
|
|
167
|
+
backgroundColor={Colors.backdrop}
|
|
168
|
+
onMouseUp={entry.closeOnClickOutside ? () => onResolve(false) : undefined}
|
|
169
|
+
>
|
|
170
|
+
<box
|
|
171
|
+
flexDirection="column"
|
|
172
|
+
gap={1}
|
|
173
|
+
padding={2}
|
|
174
|
+
border={true}
|
|
175
|
+
borderStyle="double"
|
|
176
|
+
borderColor={Colors.accent}
|
|
177
|
+
backgroundColor={Colors.card}
|
|
178
|
+
minWidth={44}
|
|
179
|
+
onMouseUp={(e: { stopPropagation?: () => void }) => {
|
|
180
|
+
e?.stopPropagation?.();
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<text fg={Colors.accent} bold>
|
|
184
|
+
{entry.title}
|
|
185
|
+
</text>
|
|
186
|
+
<text fg={Colors.foreground}>{entry.message}</text>
|
|
187
|
+
<box flexDirection="row" gap={2} paddingTop={1}>
|
|
188
|
+
<text
|
|
189
|
+
fg={Colors.accent}
|
|
190
|
+
bg={Colors.card}
|
|
191
|
+
bold
|
|
192
|
+
onMouseUp={() => onResolve(true)}
|
|
193
|
+
>
|
|
194
|
+
{` [Y/Enter] ${entry.confirmLabel} `}
|
|
195
|
+
</text>
|
|
196
|
+
<text fg={Colors.muted} onMouseUp={() => onResolve(false)}>
|
|
197
|
+
{` [N/Esc] ${entry.cancelLabel} `}
|
|
198
|
+
</text>
|
|
199
|
+
</box>
|
|
200
|
+
</box>
|
|
201
|
+
</box>
|
|
202
|
+
);
|
|
88
203
|
}
|
|
89
204
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
accentColor,
|
|
98
|
-
foregroundColor,
|
|
99
|
-
mutedColor,
|
|
100
|
-
highlightColor,
|
|
101
|
-
resolve,
|
|
102
|
-
dismiss,
|
|
103
|
-
dialogId,
|
|
104
|
-
}: ChoiceListProps<K>) {
|
|
205
|
+
function ChoiceOverlay({
|
|
206
|
+
entry,
|
|
207
|
+
onResolve,
|
|
208
|
+
}: {
|
|
209
|
+
entry: Extract<StackEntry, { kind: "choice" }>;
|
|
210
|
+
onResolve: (value: string | undefined) => void;
|
|
211
|
+
}) {
|
|
105
212
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
106
213
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
214
|
+
useKeyboard((key) => {
|
|
215
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
216
|
+
if (name === "down" || name === "j") {
|
|
217
|
+
setSelectedIndex((prev) =>
|
|
218
|
+
prev < entry.choices.length - 1 ? prev + 1 : 0
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
110
221
|
}
|
|
111
|
-
if (
|
|
112
|
-
setSelectedIndex((prev) =>
|
|
222
|
+
if (name === "up" || name === "k") {
|
|
223
|
+
setSelectedIndex((prev) =>
|
|
224
|
+
prev > 0 ? prev - 1 : entry.choices.length - 1
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
113
227
|
}
|
|
114
|
-
if (
|
|
115
|
-
const choice = choices[selectedIndex];
|
|
116
|
-
if (choice)
|
|
228
|
+
if (name === "return" || name === "enter") {
|
|
229
|
+
const choice = entry.choices[selectedIndex];
|
|
230
|
+
if (choice) onResolve(choice.key);
|
|
231
|
+
return;
|
|
117
232
|
}
|
|
118
|
-
if (
|
|
119
|
-
|
|
233
|
+
if (name === "escape") {
|
|
234
|
+
onResolve(entry.fallback);
|
|
120
235
|
}
|
|
121
|
-
}
|
|
236
|
+
});
|
|
122
237
|
|
|
123
238
|
return (
|
|
124
|
-
<box
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
239
|
+
<box
|
|
240
|
+
position="absolute"
|
|
241
|
+
left={0}
|
|
242
|
+
top={0}
|
|
243
|
+
width="100%"
|
|
244
|
+
height="100%"
|
|
245
|
+
justifyContent="center"
|
|
246
|
+
alignItems="center"
|
|
247
|
+
backgroundColor={Colors.backdrop}
|
|
248
|
+
onMouseUp={
|
|
249
|
+
entry.closeOnClickOutside ? () => onResolve(entry.fallback) : undefined
|
|
250
|
+
}
|
|
251
|
+
>
|
|
252
|
+
<box
|
|
253
|
+
flexDirection="column"
|
|
254
|
+
gap={1}
|
|
255
|
+
padding={2}
|
|
256
|
+
border={true}
|
|
257
|
+
borderStyle="double"
|
|
258
|
+
borderColor={Colors.accent}
|
|
259
|
+
backgroundColor={Colors.card}
|
|
260
|
+
minWidth={48}
|
|
261
|
+
>
|
|
262
|
+
<text fg={Colors.accent} bold>
|
|
263
|
+
{entry.title}
|
|
264
|
+
</text>
|
|
265
|
+
<text dim fg={Colors.muted}>
|
|
266
|
+
Use ↑↓ to navigate, Enter to select, Esc to cancel
|
|
267
|
+
</text>
|
|
268
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
269
|
+
{entry.choices.map((choice, idx) => (
|
|
270
|
+
<box
|
|
271
|
+
key={choice.key}
|
|
272
|
+
flexDirection="row"
|
|
273
|
+
gap={2}
|
|
274
|
+
paddingLeft={1}
|
|
275
|
+
paddingRight={1}
|
|
276
|
+
>
|
|
277
|
+
<text fg={idx === selectedIndex ? Colors.accent : Colors.muted}>
|
|
278
|
+
{idx === selectedIndex ? "▶" : " "}
|
|
279
|
+
</text>
|
|
280
|
+
<text
|
|
281
|
+
fg={idx === selectedIndex ? Colors.foreground : Colors.muted}
|
|
282
|
+
bg={idx === selectedIndex ? Colors.card : undefined}
|
|
283
|
+
onMouseUp={() => onResolve(choice.key)}
|
|
284
|
+
>
|
|
285
|
+
{choice.label}
|
|
286
|
+
</text>
|
|
287
|
+
{choice.description ? (
|
|
288
|
+
<text dim fg={Colors.muted}>
|
|
289
|
+
— {choice.description}
|
|
290
|
+
</text>
|
|
291
|
+
) : null}
|
|
292
|
+
</box>
|
|
293
|
+
))}
|
|
294
|
+
</box>
|
|
295
|
+
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
296
|
+
<text fg={Colors.muted} onMouseUp={() => onResolve(entry.fallback)}>
|
|
297
|
+
{" Cancel "}
|
|
138
298
|
</text>
|
|
139
|
-
{/* Optional description */}
|
|
140
|
-
{choice.description && (
|
|
141
|
-
<text dim fg={mutedColor}>
|
|
142
|
-
— {choice.description}
|
|
143
|
-
</text>
|
|
144
|
-
)}
|
|
145
299
|
</box>
|
|
146
|
-
|
|
300
|
+
</box>
|
|
301
|
+
</box>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function LoadingOverlay({ message }: { message: string }) {
|
|
306
|
+
return (
|
|
307
|
+
<box
|
|
308
|
+
position="absolute"
|
|
309
|
+
left={0}
|
|
310
|
+
top={0}
|
|
311
|
+
width="100%"
|
|
312
|
+
height="100%"
|
|
313
|
+
justifyContent="center"
|
|
314
|
+
alignItems="center"
|
|
315
|
+
backgroundColor={Colors.backdrop}
|
|
316
|
+
>
|
|
317
|
+
<box
|
|
318
|
+
flexDirection="column"
|
|
319
|
+
padding={2}
|
|
320
|
+
gap={1}
|
|
321
|
+
border={true}
|
|
322
|
+
borderStyle="single"
|
|
323
|
+
borderColor={Colors.border}
|
|
324
|
+
backgroundColor={Colors.card}
|
|
325
|
+
justifyContent="center"
|
|
326
|
+
alignItems="center"
|
|
327
|
+
>
|
|
328
|
+
<text fg={Colors.accent}>{message}</text>
|
|
329
|
+
<text dim fg={Colors.muted}>
|
|
330
|
+
Please wait...
|
|
331
|
+
</text>
|
|
332
|
+
</box>
|
|
147
333
|
</box>
|
|
148
334
|
);
|
|
149
335
|
}
|
|
150
336
|
|
|
151
|
-
// ──
|
|
337
|
+
// ── Provider ───────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
export interface DialogProviderProps {
|
|
340
|
+
children: ReactNode;
|
|
341
|
+
/** Accepted for API compat with old @opentui-ui/dialog provider; unused. */
|
|
342
|
+
size?: string;
|
|
343
|
+
backdropColor?: string;
|
|
344
|
+
backdropOpacity?: number;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function DialogProvider({ children }: DialogProviderProps) {
|
|
348
|
+
const [stack, setStack] = useState<StackEntry[]>([]);
|
|
349
|
+
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
setOpenDialogCount(stack.length);
|
|
352
|
+
return () => setOpenDialogCount(0);
|
|
353
|
+
}, [stack.length]);
|
|
354
|
+
|
|
355
|
+
const popId = useCallback((id: number) => {
|
|
356
|
+
setStack((s) => s.filter((e) => e.id !== id));
|
|
357
|
+
}, []);
|
|
358
|
+
|
|
359
|
+
const handle = useMemo<DialogHandle>(() => {
|
|
360
|
+
const confirmSimple = (options: ConfirmDialogOptions) =>
|
|
361
|
+
new Promise<boolean>((resolve) => {
|
|
362
|
+
const id = nextId++;
|
|
363
|
+
setStack((s) => [
|
|
364
|
+
...s,
|
|
365
|
+
{
|
|
366
|
+
kind: "confirm",
|
|
367
|
+
id,
|
|
368
|
+
title: options.title,
|
|
369
|
+
message: options.message,
|
|
370
|
+
confirmLabel: options.confirmLabel ?? "Confirm",
|
|
371
|
+
cancelLabel: options.cancelLabel ?? "Cancel",
|
|
372
|
+
closeOnClickOutside: options.closeOnClickOutside ?? true,
|
|
373
|
+
resolve: (value) => {
|
|
374
|
+
popId(id);
|
|
375
|
+
resolve(value);
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const choiceSimple = <K extends string>(options: ChoiceDialogOptions<K>) =>
|
|
382
|
+
new Promise<K | undefined>((resolve) => {
|
|
383
|
+
const id = nextId++;
|
|
384
|
+
setStack((s) => [
|
|
385
|
+
...s,
|
|
386
|
+
{
|
|
387
|
+
kind: "choice",
|
|
388
|
+
id,
|
|
389
|
+
title: options.title,
|
|
390
|
+
choices: options.choices as ChoiceOption[],
|
|
391
|
+
fallback: options.fallback,
|
|
392
|
+
closeOnClickOutside: options.closeOnClickOutside ?? true,
|
|
393
|
+
resolve: (value) => {
|
|
394
|
+
popId(id);
|
|
395
|
+
resolve(value as K | undefined);
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
confirmSimple,
|
|
403
|
+
choiceSimple,
|
|
404
|
+
confirm: (options) => {
|
|
405
|
+
// Tests pass title/message via custom shapes; content() is ignored.
|
|
406
|
+
const title =
|
|
407
|
+
options.title ??
|
|
408
|
+
(typeof options.content === "function" ? "Confirm" : "Confirm");
|
|
409
|
+
const message = options.message ?? "Continue?";
|
|
410
|
+
return confirmSimple({
|
|
411
|
+
title,
|
|
412
|
+
message,
|
|
413
|
+
confirmLabel: options.confirmLabel,
|
|
414
|
+
cancelLabel: options.cancelLabel,
|
|
415
|
+
closeOnClickOutside: options.closeOnClickOutside,
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
choice: <K extends string>(options: {
|
|
419
|
+
title?: string;
|
|
420
|
+
choices?: ChoiceOption<K>[];
|
|
421
|
+
fallback?: K;
|
|
422
|
+
closeOnClickOutside?: boolean;
|
|
423
|
+
}) => {
|
|
424
|
+
return choiceSimple<K>({
|
|
425
|
+
title: options.title ?? "Select",
|
|
426
|
+
choices: options.choices ?? [],
|
|
427
|
+
fallback: options.fallback,
|
|
428
|
+
closeOnClickOutside: options.closeOnClickOutside,
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
show: (options) => {
|
|
432
|
+
const id = nextId++;
|
|
433
|
+
setStack((s) => [
|
|
434
|
+
...s,
|
|
435
|
+
{
|
|
436
|
+
kind: "loading",
|
|
437
|
+
id,
|
|
438
|
+
message: options.message ?? "Loading…",
|
|
439
|
+
},
|
|
440
|
+
]);
|
|
441
|
+
return id;
|
|
442
|
+
},
|
|
443
|
+
close: (id?) => {
|
|
444
|
+
if (id == null) {
|
|
445
|
+
setStack([]);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
popId(Number(id));
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}, [popId]);
|
|
452
|
+
|
|
453
|
+
const top = stack[stack.length - 1] ?? null;
|
|
454
|
+
|
|
455
|
+
// Mark shell overlay open when a dialog is showing so view keys are gated.
|
|
456
|
+
// We piggy-back on ui-store modal only for keyboard gate — dialogs render here.
|
|
457
|
+
// Views use isShellOverlayOpen() which checks commandPalette + modal.
|
|
458
|
+
// Dialogs also use useKeyboard at higher priority than views when mounted last.
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<DialogCtx.Provider value={handle}>
|
|
462
|
+
{children}
|
|
463
|
+
{top?.kind === "confirm" ? (
|
|
464
|
+
<ConfirmOverlay
|
|
465
|
+
entry={top}
|
|
466
|
+
onResolve={(v) => {
|
|
467
|
+
top.resolve(v);
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
) : null}
|
|
471
|
+
{top?.kind === "choice" ? (
|
|
472
|
+
<ChoiceOverlay
|
|
473
|
+
entry={top}
|
|
474
|
+
onResolve={(v) => {
|
|
475
|
+
top.resolve(v);
|
|
476
|
+
}}
|
|
477
|
+
/>
|
|
478
|
+
) : null}
|
|
479
|
+
{top?.kind === "loading" ? (
|
|
480
|
+
<LoadingOverlay message={top.message} />
|
|
481
|
+
) : null}
|
|
482
|
+
</DialogCtx.Provider>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function useDialog(): DialogHandle {
|
|
487
|
+
const ctx = useContext(DialogCtx);
|
|
488
|
+
if (!ctx) {
|
|
489
|
+
throw new Error("useDialog must be used within <DialogProvider>");
|
|
490
|
+
}
|
|
491
|
+
return ctx;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── Public helpers (stable call sites) ─────────────────────────────────────
|
|
152
495
|
|
|
153
496
|
/**
|
|
154
497
|
* Show a confirmation dialog styled with Hoox colors.
|
|
155
498
|
* Returns `true` if confirmed, `false` if canceled or dismissed.
|
|
156
|
-
*
|
|
157
|
-
* @example
|
|
158
|
-
* const confirmed = await showConfirm(dialog, {
|
|
159
|
-
* title: "Delete Worker",
|
|
160
|
-
* message: "This action cannot be undone.",
|
|
161
|
-
* confirmLabel: "Delete",
|
|
162
|
-
* cancelLabel: "Keep",
|
|
163
|
-
* })
|
|
164
499
|
*/
|
|
165
500
|
export async function showConfirm(
|
|
166
501
|
dialog: DialogHandle,
|
|
167
502
|
options: ConfirmDialogOptions
|
|
168
503
|
): Promise<boolean> {
|
|
504
|
+
if (typeof dialog.confirmSimple === "function") {
|
|
505
|
+
return dialog.confirmSimple(options);
|
|
506
|
+
}
|
|
507
|
+
// Fallback for test doubles that only implement confirm()
|
|
169
508
|
return dialog.confirm({
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<text fg={Colors.muted}>{options.message}</text>
|
|
176
|
-
<box flexDirection="row" gap={2} justifyContent="flex-end">
|
|
177
|
-
<text fg={Colors.muted} onMouseUp={() => ctx.resolve(false)}>
|
|
178
|
-
{" "}
|
|
179
|
-
{options.cancelLabel ?? "Cancel"}
|
|
180
|
-
{" "}
|
|
181
|
-
</text>
|
|
182
|
-
<text
|
|
183
|
-
fg={Colors.accent}
|
|
184
|
-
bg={Colors.card}
|
|
185
|
-
onMouseUp={() => ctx.resolve(true)}
|
|
186
|
-
>
|
|
187
|
-
{" "}
|
|
188
|
-
{options.confirmLabel ?? "Confirm"}
|
|
189
|
-
{" "}
|
|
190
|
-
</text>
|
|
191
|
-
</box>
|
|
192
|
-
</box>
|
|
193
|
-
),
|
|
194
|
-
closeOnClickOutside: options.closeOnClickOutside ?? true,
|
|
509
|
+
title: options.title,
|
|
510
|
+
message: options.message,
|
|
511
|
+
confirmLabel: options.confirmLabel,
|
|
512
|
+
cancelLabel: options.cancelLabel,
|
|
513
|
+
closeOnClickOutside: options.closeOnClickOutside,
|
|
195
514
|
});
|
|
196
515
|
}
|
|
197
516
|
|
|
198
517
|
/**
|
|
199
518
|
* Show a multiple-choice dialog styled with Hoox colors.
|
|
200
519
|
* Returns the selected choice key, or `fallback` if dismissed.
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* const action = await showChoice(dialog, {
|
|
204
|
-
* title: "Select Action",
|
|
205
|
-
* choices: [
|
|
206
|
-
* { key: "restart", label: "Restart Worker", description: "Graceful restart" },
|
|
207
|
-
* { key: "stop", label: "Stop Worker", description: "Immediate halt" },
|
|
208
|
-
* ],
|
|
209
|
-
* })
|
|
210
520
|
*/
|
|
211
521
|
export async function showChoice<K extends string>(
|
|
212
522
|
dialog: DialogHandle,
|
|
213
523
|
options: ChoiceDialogOptions<K>
|
|
214
524
|
): Promise<K | undefined> {
|
|
525
|
+
if (typeof dialog.choiceSimple === "function") {
|
|
526
|
+
return dialog.choiceSimple(options);
|
|
527
|
+
}
|
|
215
528
|
return dialog.choice<K>({
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
{/* Header */}
|
|
219
|
-
<text fg={Colors.accent}>
|
|
220
|
-
<b>{options.title}</b>
|
|
221
|
-
</text>
|
|
222
|
-
<text dim fg={Colors.muted}>
|
|
223
|
-
Use ↑↓ to navigate, Enter to select, Esc to cancel
|
|
224
|
-
</text>
|
|
225
|
-
|
|
226
|
-
{/* Divider */}
|
|
227
|
-
<box height={1} />
|
|
228
|
-
|
|
229
|
-
{/* Keyboard-navigable choice list */}
|
|
230
|
-
<ChoiceList
|
|
231
|
-
choices={options.choices}
|
|
232
|
-
accentColor={Colors.accent}
|
|
233
|
-
foregroundColor={Colors.foreground}
|
|
234
|
-
mutedColor={Colors.muted}
|
|
235
|
-
highlightColor={Colors.card}
|
|
236
|
-
resolve={ctx.resolve}
|
|
237
|
-
dismiss={ctx.dismiss}
|
|
238
|
-
dialogId={ctx.dialogId}
|
|
239
|
-
/>
|
|
240
|
-
|
|
241
|
-
{/* Footer actions */}
|
|
242
|
-
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
243
|
-
<text fg={Colors.muted} onMouseUp={ctx.dismiss}>
|
|
244
|
-
{" Cancel "}
|
|
245
|
-
</text>
|
|
246
|
-
</box>
|
|
247
|
-
</box>
|
|
248
|
-
),
|
|
529
|
+
title: options.title,
|
|
530
|
+
choices: options.choices,
|
|
249
531
|
fallback: options.fallback,
|
|
250
|
-
closeOnClickOutside: options.closeOnClickOutside
|
|
532
|
+
closeOnClickOutside: options.closeOnClickOutside,
|
|
251
533
|
});
|
|
252
534
|
}
|
|
253
535
|
|
|
254
536
|
/**
|
|
255
|
-
* Show a non-interactive loading dialog
|
|
256
|
-
* Returns a `close` function to dismiss
|
|
257
|
-
*
|
|
258
|
-
* @example
|
|
259
|
-
* const close = showLoading(dialog, "Deploying workers...")
|
|
260
|
-
* await deployAll()
|
|
261
|
-
* close()
|
|
537
|
+
* Show a non-interactive loading dialog.
|
|
538
|
+
* Returns a `close` function to dismiss when work completes.
|
|
262
539
|
*/
|
|
263
540
|
export function showLoading(dialog: DialogHandle, message: string): () => void {
|
|
264
|
-
const id = dialog.show({
|
|
265
|
-
content: () => (
|
|
266
|
-
<box
|
|
267
|
-
flexDirection="column"
|
|
268
|
-
padding={2}
|
|
269
|
-
gap={1}
|
|
270
|
-
justifyContent="center"
|
|
271
|
-
alignItems="center"
|
|
272
|
-
>
|
|
273
|
-
<text fg={Colors.accent}>{message}</text>
|
|
274
|
-
<text dim fg={Colors.muted}>
|
|
275
|
-
Please wait...
|
|
276
|
-
</text>
|
|
277
|
-
</box>
|
|
278
|
-
),
|
|
279
|
-
});
|
|
541
|
+
const id = dialog.show({ message });
|
|
280
542
|
return () => dialog.close(id);
|
|
281
543
|
}
|
|
544
|
+
|
|
545
|
+
// Re-export for select.tsx / tests that referenced dialog keyboard helpers
|
|
546
|
+
export type DialogId = string | number;
|
|
547
|
+
|
|
548
|
+
/** @deprecated No-op; local keyboard is handled inside overlays. */
|
|
549
|
+
export function useDialogKeyboard(
|
|
550
|
+
_handler: (key: { name?: string }) => void,
|
|
551
|
+
_dialogId?: DialogId
|
|
552
|
+
): void {
|
|
553
|
+
// Overlays own their own useKeyboard; this stub avoids @opentui-ui/dialog.
|
|
554
|
+
void _handler;
|
|
555
|
+
void _dialogId;
|
|
556
|
+
}
|