@dex-ai/vue-tui 0.1.10
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/package.json +51 -0
- package/src/app.ts +385 -0
- package/src/components/TuiCheckbox.ts +35 -0
- package/src/components/TuiField.ts +123 -0
- package/src/components/TuiSelect.ts +86 -0
- package/src/components/index.ts +7 -0
- package/src/composables/index.ts +19 -0
- package/src/composables/useFocusList.ts +101 -0
- package/src/composables/useForm.ts +335 -0
- package/src/composables/useSelectList.ts +199 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +131 -0
- package/src/input.ts +603 -0
- package/src/nodes.ts +153 -0
- package/src/panel.ts +51 -0
- package/src/plugin.ts +148 -0
- package/src/register.ts +4 -0
- package/src/render.ts +632 -0
- package/src/renderer.ts +120 -0
- package/src/style.ts +107 -0
- package/src/template.ts +326 -0
- package/src/text-buffer.ts +609 -0
- package/src/theme.ts +44 -0
- package/src/types/form.ts +90 -0
- package/src/widget-renderer.ts +237 -0
- package/src/widget.ts +326 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui/composables — barrel export
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export { useForm } from "./useForm";
|
|
6
|
+
export type { UseFormOptions, UseFormReturn } from "./useForm";
|
|
7
|
+
|
|
8
|
+
export { useFocusList } from "./useFocusList";
|
|
9
|
+
export type { UseFocusListOptions, FocusListState } from "./useFocusList";
|
|
10
|
+
|
|
11
|
+
export { useSelectList } from "./useSelectList";
|
|
12
|
+
export type { UseSelectListOptions, SelectListState } from "./useSelectList";
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
FormField,
|
|
16
|
+
FormState,
|
|
17
|
+
FormActions,
|
|
18
|
+
FormResult,
|
|
19
|
+
} from "../types/form";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui — useFocusList composable
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for useFocusList.
|
|
7
|
+
*/
|
|
8
|
+
export interface UseFocusListOptions {
|
|
9
|
+
/** Number of items, or a getter that returns the count. */
|
|
10
|
+
count: number | (() => number);
|
|
11
|
+
/** Whether navigation wraps around. Default: true. */
|
|
12
|
+
wrap?: boolean | undefined;
|
|
13
|
+
/** Initial focused index. Default: 0. */
|
|
14
|
+
initialIndex?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return type for useFocusList.
|
|
19
|
+
*/
|
|
20
|
+
export interface FocusListState {
|
|
21
|
+
/** Currently focused index. */
|
|
22
|
+
index: number;
|
|
23
|
+
/** Move focus up (decrement). */
|
|
24
|
+
up(): void;
|
|
25
|
+
/** Move focus down (increment). */
|
|
26
|
+
down(): void;
|
|
27
|
+
/** Set focus to a specific index. */
|
|
28
|
+
set(i: number): void;
|
|
29
|
+
/**
|
|
30
|
+
* Handle a key event. Returns true if consumed.
|
|
31
|
+
* Handles "up" and "down" key names.
|
|
32
|
+
*/
|
|
33
|
+
handleKey(key: { name: string }): boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Composable for managing a focused index in a list.
|
|
38
|
+
* Handles up/down navigation with optional wrapping.
|
|
39
|
+
*/
|
|
40
|
+
export function useFocusList(options: UseFocusListOptions): FocusListState {
|
|
41
|
+
const wrap = options.wrap ?? true;
|
|
42
|
+
let index = options.initialIndex ?? 0;
|
|
43
|
+
|
|
44
|
+
function getCount(): number {
|
|
45
|
+
return typeof options.count === "function" ? options.count() : options.count;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function up(): void {
|
|
49
|
+
const count = getCount();
|
|
50
|
+
if (count === 0) return;
|
|
51
|
+
if (index > 0) {
|
|
52
|
+
index--;
|
|
53
|
+
} else if (wrap) {
|
|
54
|
+
index = count - 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function down(): void {
|
|
59
|
+
const count = getCount();
|
|
60
|
+
if (count === 0) return;
|
|
61
|
+
if (index < count - 1) {
|
|
62
|
+
index++;
|
|
63
|
+
} else if (wrap) {
|
|
64
|
+
index = 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function set(i: number): void {
|
|
69
|
+
const count = getCount();
|
|
70
|
+
if (i >= 0 && i < count) {
|
|
71
|
+
index = i;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleKey(key: { name: string }): boolean {
|
|
76
|
+
if (key.name === "up") {
|
|
77
|
+
up();
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (key.name === "down") {
|
|
81
|
+
down();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const state: FocusListState = {
|
|
88
|
+
get index() {
|
|
89
|
+
return index;
|
|
90
|
+
},
|
|
91
|
+
set index(v: number) {
|
|
92
|
+
set(v);
|
|
93
|
+
},
|
|
94
|
+
up,
|
|
95
|
+
down,
|
|
96
|
+
set,
|
|
97
|
+
handleKey,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui — useForm composable
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import type { FormField, FormState, FormActions, FormResult } from "../types/form";
|
|
6
|
+
import { useFocusList } from "./useFocusList";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Options for useForm.
|
|
10
|
+
*/
|
|
11
|
+
export interface UseFormOptions {
|
|
12
|
+
/** Field definitions. */
|
|
13
|
+
fields: FormField[];
|
|
14
|
+
/** Called when the form is submitted with valid values. */
|
|
15
|
+
onSubmit?: ((values: Record<string, string | boolean>) => void) | undefined;
|
|
16
|
+
/** Called when the form is cancelled (Escape at top level). */
|
|
17
|
+
onCancel?: (() => void) | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return type for useForm.
|
|
22
|
+
*/
|
|
23
|
+
export interface UseFormReturn {
|
|
24
|
+
/** The field definitions (passed through for rendering). */
|
|
25
|
+
fields: FormField[];
|
|
26
|
+
/** Reactive form state. */
|
|
27
|
+
state: FormState;
|
|
28
|
+
/** Actions to manipulate form state. */
|
|
29
|
+
actions: FormActions;
|
|
30
|
+
/** Current result — check after handleKey to see if form should dismiss. */
|
|
31
|
+
result: FormResult;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Composable that manages a multi-field form with focus navigation,
|
|
36
|
+
* inline editing, validation, and submission.
|
|
37
|
+
*
|
|
38
|
+
* Key bindings (not editing):
|
|
39
|
+
* - ↑/↓ — navigate fields
|
|
40
|
+
* - Enter/Tab — start editing (or toggle checkbox)
|
|
41
|
+
* - Space — toggle checkbox
|
|
42
|
+
* - Ctrl+S — submit
|
|
43
|
+
* - Escape — cancel form
|
|
44
|
+
*
|
|
45
|
+
* Key bindings (editing):
|
|
46
|
+
* - Printable chars — append to buffer
|
|
47
|
+
* - Backspace — delete from buffer
|
|
48
|
+
* - Enter/Tab — confirm edit, advance to next field
|
|
49
|
+
* - Escape — revert edit
|
|
50
|
+
*/
|
|
51
|
+
export function useForm(options: UseFormOptions): UseFormReturn {
|
|
52
|
+
const { fields } = options;
|
|
53
|
+
|
|
54
|
+
// Count of navigable fields (skip readonly for editing, but still focusable)
|
|
55
|
+
const focusList = useFocusList({
|
|
56
|
+
count: fields.length,
|
|
57
|
+
wrap: true,
|
|
58
|
+
initialIndex: 0,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Initialize values from field defaults
|
|
62
|
+
const values: Record<string, string | boolean> = {};
|
|
63
|
+
for (const field of fields) {
|
|
64
|
+
if (field.value !== undefined) {
|
|
65
|
+
values[field.key] = field.value;
|
|
66
|
+
} else if (field.type === "checkbox") {
|
|
67
|
+
values[field.key] = false;
|
|
68
|
+
} else {
|
|
69
|
+
values[field.key] = "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let editing = false;
|
|
74
|
+
let editBuffer = "";
|
|
75
|
+
let errors: Record<string, string> = {};
|
|
76
|
+
let submitted = false;
|
|
77
|
+
let result: FormResult = { status: "pending" };
|
|
78
|
+
|
|
79
|
+
// --- State accessors ---
|
|
80
|
+
|
|
81
|
+
const state: FormState = {
|
|
82
|
+
get values() {
|
|
83
|
+
return values;
|
|
84
|
+
},
|
|
85
|
+
get focusedIndex() {
|
|
86
|
+
return focusList.index;
|
|
87
|
+
},
|
|
88
|
+
get editing() {
|
|
89
|
+
return editing;
|
|
90
|
+
},
|
|
91
|
+
get editBuffer() {
|
|
92
|
+
return editBuffer;
|
|
93
|
+
},
|
|
94
|
+
get errors() {
|
|
95
|
+
return errors;
|
|
96
|
+
},
|
|
97
|
+
get submitted() {
|
|
98
|
+
return submitted;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// --- Actions ---
|
|
103
|
+
|
|
104
|
+
function focusUp(): void {
|
|
105
|
+
if (editing) return;
|
|
106
|
+
focusList.up();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function focusDown(): void {
|
|
110
|
+
if (editing) return;
|
|
111
|
+
focusList.down();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function currentField(): FormField | undefined {
|
|
115
|
+
return fields[focusList.index];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function startEdit(): void {
|
|
119
|
+
const field = currentField();
|
|
120
|
+
if (!field || field.readonly) return;
|
|
121
|
+
if (field.type === "checkbox") {
|
|
122
|
+
toggle();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
editing = true;
|
|
126
|
+
const current = values[field.key];
|
|
127
|
+
editBuffer = typeof current === "string" ? current : "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function confirmEdit(): void {
|
|
131
|
+
if (!editing) return;
|
|
132
|
+
const field = currentField();
|
|
133
|
+
if (!field) return;
|
|
134
|
+
|
|
135
|
+
// Apply the edit
|
|
136
|
+
values[field.key] = editBuffer;
|
|
137
|
+
editing = false;
|
|
138
|
+
editBuffer = "";
|
|
139
|
+
|
|
140
|
+
// Clear error for this field
|
|
141
|
+
delete errors[field.key];
|
|
142
|
+
|
|
143
|
+
// Run field validation
|
|
144
|
+
if (field.validate) {
|
|
145
|
+
const err = field.validate(values[field.key]!);
|
|
146
|
+
if (err) {
|
|
147
|
+
errors[field.key] = err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cancelEdit(): void {
|
|
153
|
+
if (!editing) return;
|
|
154
|
+
editing = false;
|
|
155
|
+
editBuffer = "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function type(char: string): void {
|
|
159
|
+
if (!editing) return;
|
|
160
|
+
editBuffer += char;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function backspace(): void {
|
|
164
|
+
if (!editing) return;
|
|
165
|
+
editBuffer = editBuffer.slice(0, -1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toggle(): void {
|
|
169
|
+
const field = currentField();
|
|
170
|
+
if (!field || field.readonly || field.type !== "checkbox") return;
|
|
171
|
+
values[field.key] = !values[field.key];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validate(): boolean {
|
|
175
|
+
errors = {};
|
|
176
|
+
let valid = true;
|
|
177
|
+
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
const value = values[field.key];
|
|
180
|
+
|
|
181
|
+
// Required check
|
|
182
|
+
if (field.required && !field.readonly) {
|
|
183
|
+
if (
|
|
184
|
+
value === undefined ||
|
|
185
|
+
value === "" ||
|
|
186
|
+
value === false
|
|
187
|
+
) {
|
|
188
|
+
errors[field.key] = `${field.label} is required`;
|
|
189
|
+
valid = false;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Custom validation
|
|
195
|
+
if (field.validate && value !== undefined) {
|
|
196
|
+
const err = field.validate(value);
|
|
197
|
+
if (err) {
|
|
198
|
+
errors[field.key] = err;
|
|
199
|
+
valid = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return valid;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function submit(): Record<string, string | boolean> | null {
|
|
208
|
+
// Confirm any in-progress edit
|
|
209
|
+
if (editing) confirmEdit();
|
|
210
|
+
|
|
211
|
+
submitted = true;
|
|
212
|
+
|
|
213
|
+
if (!validate()) {
|
|
214
|
+
// Focus first errored field
|
|
215
|
+
const firstError = fields.findIndex((f) => errors[f.key]);
|
|
216
|
+
if (firstError >= 0) {
|
|
217
|
+
focusList.set(firstError);
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
result = { status: "submitted", values: { ...values } };
|
|
223
|
+
options.onSubmit?.({ ...values });
|
|
224
|
+
return { ...values };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function reset(): void {
|
|
228
|
+
for (const field of fields) {
|
|
229
|
+
if (field.value !== undefined) {
|
|
230
|
+
values[field.key] = field.value;
|
|
231
|
+
} else if (field.type === "checkbox") {
|
|
232
|
+
values[field.key] = false;
|
|
233
|
+
} else {
|
|
234
|
+
values[field.key] = "";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
editing = false;
|
|
238
|
+
editBuffer = "";
|
|
239
|
+
errors = {};
|
|
240
|
+
submitted = false;
|
|
241
|
+
result = { status: "pending" };
|
|
242
|
+
focusList.set(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handleKey(key: {
|
|
246
|
+
name: string;
|
|
247
|
+
char?: string | undefined;
|
|
248
|
+
ctrl?: boolean | undefined;
|
|
249
|
+
shift?: boolean | undefined;
|
|
250
|
+
}): boolean {
|
|
251
|
+
// --- Editing mode ---
|
|
252
|
+
if (editing) {
|
|
253
|
+
if (key.name === "enter" || key.name === "tab") {
|
|
254
|
+
confirmEdit();
|
|
255
|
+
// Tab advances to next field
|
|
256
|
+
if (key.name === "tab") {
|
|
257
|
+
focusList.down();
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (key.name === "escape") {
|
|
262
|
+
cancelEdit();
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (key.name === "backspace") {
|
|
266
|
+
backspace();
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if (key.name === "char" && key.char) {
|
|
270
|
+
type(key.char);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
// Ignore other keys in edit mode
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- Navigation mode ---
|
|
278
|
+
if (key.name === "up") {
|
|
279
|
+
focusUp();
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
if (key.name === "down") {
|
|
283
|
+
focusDown();
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
if (key.name === "enter" || key.name === "tab") {
|
|
287
|
+
startEdit();
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
if (key.name === "char" && key.char === " ") {
|
|
291
|
+
const field = currentField();
|
|
292
|
+
if (field?.type === "checkbox") {
|
|
293
|
+
toggle();
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
// Space on non-checkbox starts edit
|
|
297
|
+
startEdit();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (key.name === "s" && key.ctrl) {
|
|
301
|
+
submit();
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (key.name === "escape") {
|
|
305
|
+
result = { status: "cancelled" };
|
|
306
|
+
options.onCancel?.();
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const actions: FormActions = {
|
|
314
|
+
focusUp,
|
|
315
|
+
focusDown,
|
|
316
|
+
startEdit,
|
|
317
|
+
confirmEdit,
|
|
318
|
+
cancelEdit,
|
|
319
|
+
type,
|
|
320
|
+
backspace,
|
|
321
|
+
toggle,
|
|
322
|
+
submit,
|
|
323
|
+
reset,
|
|
324
|
+
handleKey,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
fields,
|
|
329
|
+
state,
|
|
330
|
+
actions,
|
|
331
|
+
get result() {
|
|
332
|
+
return result;
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @dex-ai/vue-tui — useSelectList composable
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for useSelectList.
|
|
7
|
+
*/
|
|
8
|
+
export interface UseSelectListOptions {
|
|
9
|
+
/** Available items, or a getter. */
|
|
10
|
+
items: string[] | (() => string[]);
|
|
11
|
+
/** Whether type-ahead filtering is enabled. Default: true. */
|
|
12
|
+
allowFilter?: boolean | undefined;
|
|
13
|
+
/** Maximum number of visible items (for scroll windowing). */
|
|
14
|
+
maxVisible?: number | undefined;
|
|
15
|
+
/** Called when an item is selected. */
|
|
16
|
+
onSelect?: ((item: string, index: number) => void) | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return type for useSelectList.
|
|
21
|
+
*/
|
|
22
|
+
export interface SelectListState {
|
|
23
|
+
/** Filtered items (or all items if filter is empty). */
|
|
24
|
+
readonly filtered: string[];
|
|
25
|
+
/** Currently highlighted index within filtered list. */
|
|
26
|
+
selectedIndex: number;
|
|
27
|
+
/** Current filter string. */
|
|
28
|
+
filter: string;
|
|
29
|
+
/** Scroll offset for windowed display. */
|
|
30
|
+
readonly scrollOffset: number;
|
|
31
|
+
/** Move highlight up. */
|
|
32
|
+
up(): void;
|
|
33
|
+
/** Move highlight down. */
|
|
34
|
+
down(): void;
|
|
35
|
+
/** Confirm selection. Returns the selected item or null. */
|
|
36
|
+
select(): string | null;
|
|
37
|
+
/** Append a character to the filter. */
|
|
38
|
+
type(char: string): void;
|
|
39
|
+
/** Delete last character from the filter. */
|
|
40
|
+
backspace(): void;
|
|
41
|
+
/** Clear the filter. */
|
|
42
|
+
clearFilter(): void;
|
|
43
|
+
/**
|
|
44
|
+
* Handle a key event. Returns true if consumed.
|
|
45
|
+
* Handles: up, down, enter (select), backspace, printable chars.
|
|
46
|
+
*/
|
|
47
|
+
handleKey(key: { name: string; char?: string | undefined }): boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Composable for a filterable select list with type-ahead and scroll windowing.
|
|
52
|
+
*/
|
|
53
|
+
export function useSelectList(options: UseSelectListOptions): SelectListState {
|
|
54
|
+
const allowFilter = options.allowFilter ?? true;
|
|
55
|
+
const maxVisible = options.maxVisible ?? 10;
|
|
56
|
+
|
|
57
|
+
let filter = "";
|
|
58
|
+
let selectedIndex = 0;
|
|
59
|
+
let scrollOffset = 0;
|
|
60
|
+
|
|
61
|
+
function getItems(): string[] {
|
|
62
|
+
return typeof options.items === "function" ? options.items() : options.items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getFiltered(): string[] {
|
|
66
|
+
const items = getItems();
|
|
67
|
+
if (!filter) return items;
|
|
68
|
+
const lower = filter.toLowerCase();
|
|
69
|
+
return items.filter((item) => item.toLowerCase().includes(lower));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function clampIndex(): void {
|
|
73
|
+
const filtered = getFiltered();
|
|
74
|
+
if (filtered.length === 0) {
|
|
75
|
+
selectedIndex = 0;
|
|
76
|
+
} else if (selectedIndex >= filtered.length) {
|
|
77
|
+
selectedIndex = filtered.length - 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function updateScroll(): void {
|
|
82
|
+
const filtered = getFiltered();
|
|
83
|
+
const visCount = Math.min(maxVisible, filtered.length);
|
|
84
|
+
if (selectedIndex < scrollOffset) {
|
|
85
|
+
scrollOffset = selectedIndex;
|
|
86
|
+
} else if (selectedIndex >= scrollOffset + visCount) {
|
|
87
|
+
scrollOffset = selectedIndex - visCount + 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function up(): void {
|
|
92
|
+
const filtered = getFiltered();
|
|
93
|
+
if (filtered.length === 0) return;
|
|
94
|
+
if (selectedIndex > 0) {
|
|
95
|
+
selectedIndex--;
|
|
96
|
+
} else {
|
|
97
|
+
selectedIndex = filtered.length - 1;
|
|
98
|
+
}
|
|
99
|
+
updateScroll();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function down(): void {
|
|
103
|
+
const filtered = getFiltered();
|
|
104
|
+
if (filtered.length === 0) return;
|
|
105
|
+
if (selectedIndex < filtered.length - 1) {
|
|
106
|
+
selectedIndex++;
|
|
107
|
+
} else {
|
|
108
|
+
selectedIndex = 0;
|
|
109
|
+
}
|
|
110
|
+
updateScroll();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function select(): string | null {
|
|
114
|
+
const filtered = getFiltered();
|
|
115
|
+
if (filtered.length === 0) return null;
|
|
116
|
+
const item = filtered[selectedIndex];
|
|
117
|
+
if (item === undefined) return null;
|
|
118
|
+
options.onSelect?.(item, selectedIndex);
|
|
119
|
+
return item;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function type(char: string): void {
|
|
123
|
+
if (!allowFilter) return;
|
|
124
|
+
filter += char;
|
|
125
|
+
clampIndex();
|
|
126
|
+
updateScroll();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function backspace(): void {
|
|
130
|
+
if (!allowFilter || filter.length === 0) return;
|
|
131
|
+
filter = filter.slice(0, -1);
|
|
132
|
+
clampIndex();
|
|
133
|
+
updateScroll();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clearFilter(): void {
|
|
137
|
+
filter = "";
|
|
138
|
+
clampIndex();
|
|
139
|
+
updateScroll();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleKey(key: { name: string; char?: string | undefined }): boolean {
|
|
143
|
+
if (key.name === "up") {
|
|
144
|
+
up();
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (key.name === "down") {
|
|
148
|
+
down();
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (key.name === "enter") {
|
|
152
|
+
select();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (key.name === "backspace") {
|
|
156
|
+
backspace();
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (key.name === "char" && key.char) {
|
|
160
|
+
type(key.char);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const state: SelectListState = {
|
|
167
|
+
get filtered() {
|
|
168
|
+
return getFiltered();
|
|
169
|
+
},
|
|
170
|
+
get selectedIndex() {
|
|
171
|
+
return selectedIndex;
|
|
172
|
+
},
|
|
173
|
+
set selectedIndex(v: number) {
|
|
174
|
+
selectedIndex = v;
|
|
175
|
+
clampIndex();
|
|
176
|
+
updateScroll();
|
|
177
|
+
},
|
|
178
|
+
get filter() {
|
|
179
|
+
return filter;
|
|
180
|
+
},
|
|
181
|
+
set filter(v: string) {
|
|
182
|
+
filter = v;
|
|
183
|
+
clampIndex();
|
|
184
|
+
updateScroll();
|
|
185
|
+
},
|
|
186
|
+
get scrollOffset() {
|
|
187
|
+
return scrollOffset;
|
|
188
|
+
},
|
|
189
|
+
up,
|
|
190
|
+
down,
|
|
191
|
+
select,
|
|
192
|
+
type,
|
|
193
|
+
backspace,
|
|
194
|
+
clearFilter,
|
|
195
|
+
handleKey,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return state;
|
|
199
|
+
}
|