@hoox-sh/hoox-tui 0.3.1 → 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 +586 -285
- package/package.json +1 -6
- 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/views/service-manager.test.tsx +8 -27
- package/src/hooks/shell-overlay.ts +3 -2
|
@@ -5,23 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
/** @jsxImportSource @opentui/react */
|
|
7
7
|
/**
|
|
8
|
-
* SelectModal —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Keyboard navigation:
|
|
12
|
-
* - Up/Down (or j/k): move selection
|
|
13
|
-
* - Enter: confirm selection
|
|
14
|
-
* - Escape: dismiss
|
|
15
|
-
*
|
|
16
|
-
* All colors use Colors tokens from @hoox-sh/hoox-shared (no hardcoded hex).
|
|
8
|
+
* SelectModal — keyboard-navigable option picker via in-house DialogHandle.
|
|
9
|
+
* Does not depend on @opentui-ui/dialog (nested OpenTUI crash on global install).
|
|
17
10
|
*/
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
type DialogId,
|
|
21
|
-
type PromptContext,
|
|
22
|
-
useDialogKeyboard,
|
|
23
|
-
} from "@opentui-ui/dialog/react";
|
|
24
|
-
import { Colors } from "@hoox-sh/hoox-shared";
|
|
11
|
+
import type { DialogHandle } from "./dialog";
|
|
12
|
+
import { showChoice } from "./dialog";
|
|
25
13
|
|
|
26
14
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
27
15
|
|
|
@@ -45,153 +33,17 @@ export interface SelectModalOptions {
|
|
|
45
33
|
closeOnClickOutside?: boolean;
|
|
46
34
|
}
|
|
47
35
|
|
|
48
|
-
/** Dialog handle — the subset of useDialog() needed by SelectModal */
|
|
49
|
-
export interface SelectDialogHandle {
|
|
50
|
-
prompt<T>(options: {
|
|
51
|
-
content: (ctx: PromptContext<T>) => unknown;
|
|
52
|
-
fallback?: T;
|
|
53
|
-
closeOnClickOutside?: boolean;
|
|
54
|
-
}): Promise<T | undefined>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ── Internal: keyboard-navigable option list ───────────────────────────────
|
|
58
|
-
|
|
59
|
-
/** Props for the selectable option list rendered inside the dialog */
|
|
60
|
-
interface SelectListProps {
|
|
61
|
-
options: SelectOption[];
|
|
62
|
-
accentColor: string;
|
|
63
|
-
foregroundColor: string;
|
|
64
|
-
mutedColor: string;
|
|
65
|
-
highlightColor: string;
|
|
66
|
-
resolve: (value: string) => void;
|
|
67
|
-
dismiss: () => void;
|
|
68
|
-
dialogId: DialogId;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Internal component that renders a keyboard-navigable option list.
|
|
73
|
-
* Uses useDialogKeyboard scoped to the dialog so that only the topmost
|
|
74
|
-
* dialog receives keyboard events.
|
|
75
|
-
*/
|
|
76
|
-
function SelectList({
|
|
77
|
-
options,
|
|
78
|
-
accentColor,
|
|
79
|
-
foregroundColor,
|
|
80
|
-
mutedColor,
|
|
81
|
-
highlightColor,
|
|
82
|
-
resolve,
|
|
83
|
-
dismiss,
|
|
84
|
-
dialogId,
|
|
85
|
-
}: SelectListProps) {
|
|
86
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
87
|
-
|
|
88
|
-
useDialogKeyboard((key) => {
|
|
89
|
-
if (key.name === "down" || key.name === "j") {
|
|
90
|
-
setSelectedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
|
91
|
-
}
|
|
92
|
-
if (key.name === "up" || key.name === "k") {
|
|
93
|
-
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
|
94
|
-
}
|
|
95
|
-
if (key.name === "return") {
|
|
96
|
-
const selected = options[selectedIndex];
|
|
97
|
-
if (selected) resolve(selected.key);
|
|
98
|
-
}
|
|
99
|
-
if (key.name === "escape") {
|
|
100
|
-
dismiss();
|
|
101
|
-
}
|
|
102
|
-
}, dialogId);
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<box flexDirection="column" gap={0}>
|
|
106
|
-
{options.map((opt, idx) => (
|
|
107
|
-
<box flexDirection="row" gap={2} paddingLeft={1} paddingRight={1}>
|
|
108
|
-
{/* Selection indicator */}
|
|
109
|
-
<text fg={idx === selectedIndex ? accentColor : mutedColor}>
|
|
110
|
-
{idx === selectedIndex ? "▶" : " "}
|
|
111
|
-
</text>
|
|
112
|
-
{/* Option label */}
|
|
113
|
-
<text
|
|
114
|
-
fg={idx === selectedIndex ? foregroundColor : mutedColor}
|
|
115
|
-
bg={idx === selectedIndex ? highlightColor : undefined}
|
|
116
|
-
onMouseUp={() => resolve(opt.key)}
|
|
117
|
-
>
|
|
118
|
-
{opt.label}
|
|
119
|
-
</text>
|
|
120
|
-
{/* Optional description */}
|
|
121
|
-
{opt.description && (
|
|
122
|
-
<text dim fg={mutedColor}>
|
|
123
|
-
— {opt.description}
|
|
124
|
-
</text>
|
|
125
|
-
)}
|
|
126
|
-
</box>
|
|
127
|
-
))}
|
|
128
|
-
</box>
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Public API ─────────────────────────────────────────────────────────────
|
|
133
|
-
|
|
134
36
|
/**
|
|
135
37
|
* Show a modal select dialog with keyboard navigation.
|
|
136
|
-
*
|
|
137
|
-
* Returns the selected option's `key`, or `undefined` if dismissed
|
|
138
|
-
* (via Escape, clicking outside, or pressing Cancel).
|
|
139
|
-
*
|
|
140
|
-
* Keyboard controls:
|
|
141
|
-
* - Up/Down or j/k: move selection highlight
|
|
142
|
-
* - Enter: confirm current selection
|
|
143
|
-
* - Escape: dismiss
|
|
144
|
-
*
|
|
145
|
-
* @example
|
|
146
|
-
* const chosen = await showSelectModal(dialog, {
|
|
147
|
-
* title: "Select Worker",
|
|
148
|
-
* options: [
|
|
149
|
-
* { key: "worker-a", label: "Alpha Worker", description: "US-East" },
|
|
150
|
-
* { key: "worker-b", label: "Beta Worker", description: "EU-West" },
|
|
151
|
-
* ],
|
|
152
|
-
* })
|
|
38
|
+
* Returns the selected option's `key`, or `undefined` if dismissed.
|
|
153
39
|
*/
|
|
154
40
|
export async function showSelectModal(
|
|
155
|
-
dialog:
|
|
41
|
+
dialog: DialogHandle,
|
|
156
42
|
opts: SelectModalOptions
|
|
157
43
|
): Promise<string | undefined> {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<box flexDirection="column" padding={1} gap={1}>
|
|
163
|
-
{/* Header */}
|
|
164
|
-
<text fg={Colors.accent}>
|
|
165
|
-
<b>{title}</b>
|
|
166
|
-
</text>
|
|
167
|
-
<text dim fg={Colors.muted}>
|
|
168
|
-
Use ↑↓ to navigate, Enter to select, Esc to cancel
|
|
169
|
-
</text>
|
|
170
|
-
|
|
171
|
-
{/* Divider */}
|
|
172
|
-
<box height={1} />
|
|
173
|
-
|
|
174
|
-
{/* Selectable list */}
|
|
175
|
-
<SelectList
|
|
176
|
-
options={options}
|
|
177
|
-
accentColor={Colors.accent}
|
|
178
|
-
foregroundColor={Colors.foreground}
|
|
179
|
-
mutedColor={Colors.muted}
|
|
180
|
-
highlightColor={Colors.card}
|
|
181
|
-
resolve={ctx.resolve}
|
|
182
|
-
dismiss={ctx.dismiss}
|
|
183
|
-
dialogId={ctx.dialogId}
|
|
184
|
-
/>
|
|
185
|
-
|
|
186
|
-
{/* Footer actions */}
|
|
187
|
-
<box flexDirection="row" justifyContent="flex-end" paddingTop={1}>
|
|
188
|
-
<text fg={Colors.muted} onMouseUp={ctx.dismiss}>
|
|
189
|
-
{" Cancel "}
|
|
190
|
-
</text>
|
|
191
|
-
</box>
|
|
192
|
-
</box>
|
|
193
|
-
),
|
|
194
|
-
fallback: undefined,
|
|
195
|
-
closeOnClickOutside,
|
|
44
|
+
return showChoice(dialog, {
|
|
45
|
+
title: opts.title,
|
|
46
|
+
choices: opts.options,
|
|
47
|
+
closeOnClickOutside: opts.closeOnClickOutside ?? true,
|
|
196
48
|
});
|
|
197
49
|
}
|
|
@@ -60,35 +60,16 @@ function makeWorker(overrides: Partial<WorkerInfo> = {}): WorkerInfo {
|
|
|
60
60
|
/** Create a mock DialogHandle that records calls */
|
|
61
61
|
function mockDialog(shouldConfirm: boolean = true): DialogHandle {
|
|
62
62
|
return {
|
|
63
|
-
confirm: mock(
|
|
64
|
-
async (options: { content: unknown; closeOnClickOutside?: boolean }) => {
|
|
65
|
-
// Execute content to validate it renders
|
|
66
|
-
if (
|
|
67
|
-
typeof (options.content as (ctx: unknown) => unknown) === "function"
|
|
68
|
-
) {
|
|
69
|
-
(
|
|
70
|
-
options.content as (ctx: {
|
|
71
|
-
resolve: (v: boolean) => void;
|
|
72
|
-
}) => unknown
|
|
73
|
-
)({
|
|
74
|
-
resolve: () => {},
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
return shouldConfirm;
|
|
78
|
-
}
|
|
79
|
-
),
|
|
63
|
+
confirm: mock(async () => shouldConfirm),
|
|
80
64
|
choice: async <K extends string>(options: {
|
|
81
|
-
content: unknown;
|
|
82
65
|
fallback?: K;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
),
|
|
91
|
-
close: mock((_id?: string | number) => {}),
|
|
66
|
+
}): Promise<K | undefined> => options.fallback,
|
|
67
|
+
show: mock(() => "loading-1"),
|
|
68
|
+
close: mock(() => {}),
|
|
69
|
+
confirmSimple: mock(async () => shouldConfirm),
|
|
70
|
+
choiceSimple: async <K extends string>(options: {
|
|
71
|
+
fallback?: K;
|
|
72
|
+
}): Promise<K | undefined> => options.fallback,
|
|
92
73
|
};
|
|
93
74
|
}
|
|
94
75
|
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useKeyboard } from "@opentui/react";
|
|
12
12
|
import { useUIStore } from "@hoox-sh/hoox-shared";
|
|
13
|
+
import { isHooxDialogOpen } from "../components/ui/dialog-state";
|
|
13
14
|
|
|
14
|
-
/** True when palette or
|
|
15
|
+
/** True when palette, UI modal, or in-house DialogProvider owns the keyboard. */
|
|
15
16
|
export function isShellOverlayOpen(): boolean {
|
|
16
17
|
const s = useUIStore.getState();
|
|
17
|
-
return Boolean(s.commandPaletteOpen || s.modal);
|
|
18
|
+
return Boolean(s.commandPaletteOpen || s.modal || isHooxDialogOpen());
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/** Key shape from OpenTUI; fields optional at the type boundary. */
|