@vui-rs/ui 0.1.0
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/README.md +31 -0
- package/dist/index.d.ts +765 -0
- package/dist/index.js +1174 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import { computed, defineComponent, h, inject, onUnmounted, provide, reactive, ref, shallowRef, watch } from "@vue/runtime-core";
|
|
2
|
+
import { HostContextSymbol, SPINNER_PRESETS, VuiInput, VuiScrollBar, VuiScrollBox, VuiSpinner, VuiSpinner as VuiSpinner$1, useTheme, useTimeline } from "@vui-rs/vue";
|
|
3
|
+
//#region src/fuzzy.ts
|
|
4
|
+
const BONUS_CONSECUTIVE = 8;
|
|
5
|
+
const BONUS_WORD_START = 10;
|
|
6
|
+
const BONUS_LEADING = 6;
|
|
7
|
+
const PENALTY_GAP = 1;
|
|
8
|
+
function isBoundary(ch) {
|
|
9
|
+
return ch === " " || ch === "-" || ch === "_" || ch === "/" || ch === "." || ch === ":";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Greedy left-to-right subsequence match. Returns `null` when `query` is not a
|
|
13
|
+
* subsequence of `text`. An empty query matches everything with score 0 (so an
|
|
14
|
+
* empty search box shows the full list in its original order).
|
|
15
|
+
*/
|
|
16
|
+
function fuzzyMatch(query, text) {
|
|
17
|
+
if (query.length === 0) return {
|
|
18
|
+
score: 0,
|
|
19
|
+
indices: []
|
|
20
|
+
};
|
|
21
|
+
const q = query.toLowerCase();
|
|
22
|
+
const t = text.toLowerCase();
|
|
23
|
+
const indices = [];
|
|
24
|
+
let score = 0;
|
|
25
|
+
let qi = 0;
|
|
26
|
+
let prevMatch = -2;
|
|
27
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
28
|
+
if (t[ti] !== q[qi]) continue;
|
|
29
|
+
indices.push(ti);
|
|
30
|
+
if (ti === prevMatch + 1) score += BONUS_CONSECUTIVE;
|
|
31
|
+
if (ti === 0 || isBoundary(t[ti - 1])) score += BONUS_WORD_START;
|
|
32
|
+
if (ti < 4) score += BONUS_LEADING - ti;
|
|
33
|
+
score += 1;
|
|
34
|
+
prevMatch = ti;
|
|
35
|
+
qi++;
|
|
36
|
+
}
|
|
37
|
+
if (qi < q.length) return null;
|
|
38
|
+
const span = indices.length > 0 ? indices[indices.length - 1] - indices[0] : 0;
|
|
39
|
+
score -= Math.max(0, span - indices.length) * PENALTY_GAP;
|
|
40
|
+
return {
|
|
41
|
+
score,
|
|
42
|
+
indices
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Filter + rank `items` against `query` by the text `key` returns for each item.
|
|
47
|
+
* Non-matches are dropped; the rest come back best-score first. Ties keep the
|
|
48
|
+
* original order (the sort is stable), so an empty query is an identity filter.
|
|
49
|
+
*/
|
|
50
|
+
function fuzzyFilter(query, items, key) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
const m = fuzzyMatch(query, key(item));
|
|
54
|
+
if (m) out.push({
|
|
55
|
+
item,
|
|
56
|
+
score: m.score,
|
|
57
|
+
indices: m.indices
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
out.sort((a, b) => b.score - a.score);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/use-focus-trap.ts
|
|
65
|
+
/**
|
|
66
|
+
* Capture/restore focus around a modal's open state. Pass a reactive getter for
|
|
67
|
+
* whether the modal is open; when it flips false (or the component unmounts) the
|
|
68
|
+
* previously focused node is re-focused.
|
|
69
|
+
*/
|
|
70
|
+
function useFocusTrap(isOpen) {
|
|
71
|
+
const ctx = inject(HostContextSymbol, null);
|
|
72
|
+
let previouslyFocused = null;
|
|
73
|
+
function restore() {
|
|
74
|
+
const fm = ctx?.focusManager;
|
|
75
|
+
if (fm) if (previouslyFocused) fm.focus(previouslyFocused);
|
|
76
|
+
else fm.blur();
|
|
77
|
+
previouslyFocused = null;
|
|
78
|
+
}
|
|
79
|
+
watch(isOpen, (open, wasOpen) => {
|
|
80
|
+
if (open && !wasOpen) previouslyFocused = ctx?.focusManager?.current() ?? null;
|
|
81
|
+
else if (!open && wasOpen) restore();
|
|
82
|
+
}, { immediate: true });
|
|
83
|
+
onUnmounted(restore);
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/dialog.ts
|
|
87
|
+
/** Preset panel widths (columns). Height grows to content, capped by the overlay. */
|
|
88
|
+
const SIZE_WIDTH = {
|
|
89
|
+
small: 40,
|
|
90
|
+
medium: 56,
|
|
91
|
+
large: 76,
|
|
92
|
+
xlarge: 100
|
|
93
|
+
};
|
|
94
|
+
const VuiDialog = defineComponent({
|
|
95
|
+
name: "VuiDialog",
|
|
96
|
+
inheritAttrs: false,
|
|
97
|
+
props: {
|
|
98
|
+
/** v-model: whether the dialog is open. */
|
|
99
|
+
open: {
|
|
100
|
+
type: Boolean,
|
|
101
|
+
default: false
|
|
102
|
+
},
|
|
103
|
+
title: {
|
|
104
|
+
type: String,
|
|
105
|
+
default: ""
|
|
106
|
+
},
|
|
107
|
+
size: {
|
|
108
|
+
type: String,
|
|
109
|
+
default: "medium"
|
|
110
|
+
},
|
|
111
|
+
/** Backdrop dim strength (0..1 brightness multiplier); `false` for none. */
|
|
112
|
+
backdrop: {
|
|
113
|
+
type: [Number, Boolean],
|
|
114
|
+
default: .4
|
|
115
|
+
},
|
|
116
|
+
/** Esc closes the dialog (emits `update:open=false` + `close`). */
|
|
117
|
+
closeOnEsc: {
|
|
118
|
+
type: Boolean,
|
|
119
|
+
default: true
|
|
120
|
+
},
|
|
121
|
+
/**
|
|
122
|
+
* Auto-focus the panel itself on open. Default `true` for plain content
|
|
123
|
+
* dialogs (so they receive Esc). Variants with their own focusable control
|
|
124
|
+
* (select, input, buttons) pass `false` and focus that control instead; Esc
|
|
125
|
+
* still bubbles up to the panel's handler from the focused child.
|
|
126
|
+
*/
|
|
127
|
+
autofocus: {
|
|
128
|
+
type: Boolean,
|
|
129
|
+
default: true
|
|
130
|
+
},
|
|
131
|
+
/** Override the panel width (columns); defaults to the `size` preset. */
|
|
132
|
+
width: {
|
|
133
|
+
type: Number,
|
|
134
|
+
default: void 0
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
emits: ["update:open", "close"],
|
|
138
|
+
setup(props, { slots, emit, attrs }) {
|
|
139
|
+
const theme = useTheme();
|
|
140
|
+
useFocusTrap(() => props.open);
|
|
141
|
+
const width = computed(() => props.width ?? SIZE_WIDTH[props.size]);
|
|
142
|
+
function close() {
|
|
143
|
+
emit("update:open", false);
|
|
144
|
+
emit("close");
|
|
145
|
+
}
|
|
146
|
+
function onKeyDown(ev) {
|
|
147
|
+
if (ev.type !== "key") return;
|
|
148
|
+
if (props.closeOnEsc && ev.name === "escape") {
|
|
149
|
+
ev.preventDefault();
|
|
150
|
+
close();
|
|
151
|
+
}
|
|
152
|
+
attrs.onKeyDown?.(ev);
|
|
153
|
+
}
|
|
154
|
+
return () => {
|
|
155
|
+
if (!props.open) return null;
|
|
156
|
+
return h("overlay", {
|
|
157
|
+
trapFocus: true,
|
|
158
|
+
backdrop: props.backdrop,
|
|
159
|
+
alignItems: "center",
|
|
160
|
+
justifyContent: "center"
|
|
161
|
+
}, h("box", {
|
|
162
|
+
...attrs,
|
|
163
|
+
width: width.value,
|
|
164
|
+
maxHeight: { pct: .9 },
|
|
165
|
+
flexDirection: "column",
|
|
166
|
+
border: "rounded",
|
|
167
|
+
borderColor: theme.borderActive,
|
|
168
|
+
bg: theme.backgroundPanel,
|
|
169
|
+
fg: theme.text,
|
|
170
|
+
padding: {
|
|
171
|
+
left: 2,
|
|
172
|
+
right: 2,
|
|
173
|
+
top: 1,
|
|
174
|
+
bottom: 1
|
|
175
|
+
},
|
|
176
|
+
title: props.title ? ` ${props.title} ` : void 0,
|
|
177
|
+
focusable: props.autofocus,
|
|
178
|
+
focused: props.autofocus,
|
|
179
|
+
onKeyDown
|
|
180
|
+
}, slots.default?.()));
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/dialog-select.ts
|
|
186
|
+
function normalize(o) {
|
|
187
|
+
return typeof o === "string" ? {
|
|
188
|
+
label: o,
|
|
189
|
+
value: o
|
|
190
|
+
} : o;
|
|
191
|
+
}
|
|
192
|
+
const VuiDialogSelect = defineComponent({
|
|
193
|
+
name: "VuiDialogSelect",
|
|
194
|
+
props: {
|
|
195
|
+
open: {
|
|
196
|
+
type: Boolean,
|
|
197
|
+
default: false
|
|
198
|
+
},
|
|
199
|
+
title: {
|
|
200
|
+
type: String,
|
|
201
|
+
default: "Select"
|
|
202
|
+
},
|
|
203
|
+
items: {
|
|
204
|
+
type: Array,
|
|
205
|
+
default: () => []
|
|
206
|
+
},
|
|
207
|
+
placeholder: {
|
|
208
|
+
type: String,
|
|
209
|
+
default: "Search…"
|
|
210
|
+
},
|
|
211
|
+
/** Max rows of the scrolling list viewport. */
|
|
212
|
+
maxRows: {
|
|
213
|
+
type: Number,
|
|
214
|
+
default: 10
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
emits: [
|
|
218
|
+
"update:open",
|
|
219
|
+
"select",
|
|
220
|
+
"close"
|
|
221
|
+
],
|
|
222
|
+
setup(props, { emit }) {
|
|
223
|
+
const theme = useTheme();
|
|
224
|
+
const query = ref("");
|
|
225
|
+
const active = ref(0);
|
|
226
|
+
const scrollY = ref(0);
|
|
227
|
+
const options = computed(() => props.items.map(normalize));
|
|
228
|
+
const ranked = computed(() => fuzzyFilter(query.value, options.value, (o) => o.label));
|
|
229
|
+
const searching = computed(() => query.value.length > 0);
|
|
230
|
+
watch(() => props.open, (open) => {
|
|
231
|
+
if (open) {
|
|
232
|
+
query.value = "";
|
|
233
|
+
active.value = 0;
|
|
234
|
+
scrollY.value = 0;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
watch(ranked, (r) => {
|
|
238
|
+
if (active.value > r.length - 1) active.value = Math.max(0, r.length - 1);
|
|
239
|
+
});
|
|
240
|
+
watch([active, () => props.maxRows], ([a, rows]) => {
|
|
241
|
+
if (a < scrollY.value) scrollY.value = a;
|
|
242
|
+
else if (a >= scrollY.value + rows) scrollY.value = a - rows + 1;
|
|
243
|
+
});
|
|
244
|
+
function move(delta) {
|
|
245
|
+
const n = ranked.value.length;
|
|
246
|
+
if (n === 0) return;
|
|
247
|
+
active.value = (active.value + delta + n) % n;
|
|
248
|
+
}
|
|
249
|
+
function commit() {
|
|
250
|
+
const hit = ranked.value[active.value];
|
|
251
|
+
if (!hit) return;
|
|
252
|
+
emit("select", hit.item.value, hit.item);
|
|
253
|
+
emit("update:open", false);
|
|
254
|
+
emit("close");
|
|
255
|
+
}
|
|
256
|
+
function onKeyDown(ev) {
|
|
257
|
+
if (ev.type !== "key") return;
|
|
258
|
+
switch (ev.name) {
|
|
259
|
+
case "up":
|
|
260
|
+
ev.preventDefault();
|
|
261
|
+
move(-1);
|
|
262
|
+
break;
|
|
263
|
+
case "down":
|
|
264
|
+
ev.preventDefault();
|
|
265
|
+
move(1);
|
|
266
|
+
break;
|
|
267
|
+
case "pageUp":
|
|
268
|
+
ev.preventDefault();
|
|
269
|
+
active.value = Math.max(0, active.value - props.maxRows);
|
|
270
|
+
break;
|
|
271
|
+
case "pageDown":
|
|
272
|
+
ev.preventDefault();
|
|
273
|
+
active.value = Math.min(ranked.value.length - 1, active.value + props.maxRows);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function labelSpans(label, indices, on) {
|
|
278
|
+
if (indices.length === 0) return [label];
|
|
279
|
+
const set = new Set(indices);
|
|
280
|
+
const spans = [];
|
|
281
|
+
for (let i = 0; i < label.length; i++) {
|
|
282
|
+
const hit = set.has(i);
|
|
283
|
+
spans.push(h("span", {
|
|
284
|
+
fg: hit ? on ? theme.selectedText : theme.primary : void 0,
|
|
285
|
+
bold: hit
|
|
286
|
+
}, label[i]));
|
|
287
|
+
}
|
|
288
|
+
return spans;
|
|
289
|
+
}
|
|
290
|
+
function rows() {
|
|
291
|
+
const out = [];
|
|
292
|
+
let lastGroup;
|
|
293
|
+
ranked.value.forEach((r, i) => {
|
|
294
|
+
const opt = r.item;
|
|
295
|
+
if (!searching.value && opt.group && opt.group !== lastGroup) {
|
|
296
|
+
lastGroup = opt.group;
|
|
297
|
+
out.push(h("text", {
|
|
298
|
+
key: `g:${opt.group}`,
|
|
299
|
+
fg: theme.textMuted,
|
|
300
|
+
bold: true
|
|
301
|
+
}, opt.group));
|
|
302
|
+
}
|
|
303
|
+
const on = i === active.value;
|
|
304
|
+
out.push(h("box", {
|
|
305
|
+
key: `i:${opt.value}`,
|
|
306
|
+
flexDirection: "row",
|
|
307
|
+
justifyContent: "space-between",
|
|
308
|
+
bg: on ? theme.primary : void 0,
|
|
309
|
+
onMouseDown: (ev) => {
|
|
310
|
+
ev.preventDefault();
|
|
311
|
+
active.value = i;
|
|
312
|
+
commit();
|
|
313
|
+
}
|
|
314
|
+
}, [h("text", { fg: on ? theme.selectedText : theme.text }, labelSpans(opt.label, r.indices, on)), opt.hint ? h("text", { fg: on ? theme.selectedText : theme.textMuted }, opt.hint) : null]));
|
|
315
|
+
});
|
|
316
|
+
if (out.length === 0) out.push(h("text", { fg: theme.textMuted }, "No matches"));
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
return () => h(VuiDialog, {
|
|
320
|
+
open: props.open,
|
|
321
|
+
title: props.title,
|
|
322
|
+
size: "medium",
|
|
323
|
+
autofocus: false,
|
|
324
|
+
"onUpdate:open": (v) => emit("update:open", v),
|
|
325
|
+
onClose: () => emit("close")
|
|
326
|
+
}, () => [h("box", {
|
|
327
|
+
border: "rounded",
|
|
328
|
+
borderColor: theme.border,
|
|
329
|
+
padding: {
|
|
330
|
+
left: 1,
|
|
331
|
+
right: 1
|
|
332
|
+
},
|
|
333
|
+
onKeyDown
|
|
334
|
+
}, h(VuiInput, {
|
|
335
|
+
value: query.value,
|
|
336
|
+
placeholder: props.placeholder,
|
|
337
|
+
focused: true,
|
|
338
|
+
cursorColor: theme.primary,
|
|
339
|
+
"onUpdate:value": (v) => {
|
|
340
|
+
query.value = v;
|
|
341
|
+
active.value = 0;
|
|
342
|
+
},
|
|
343
|
+
onEnter: commit
|
|
344
|
+
})), h(VuiScrollBox, {
|
|
345
|
+
scrollY: scrollY.value,
|
|
346
|
+
maxHeight: props.maxRows,
|
|
347
|
+
focusable: false,
|
|
348
|
+
"onUpdate:scrollY": (y) => scrollY.value = y
|
|
349
|
+
}, { default: rows })]);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/dialog-prompt.ts
|
|
354
|
+
const VuiDialogPrompt = defineComponent({
|
|
355
|
+
name: "VuiDialogPrompt",
|
|
356
|
+
props: {
|
|
357
|
+
open: {
|
|
358
|
+
type: Boolean,
|
|
359
|
+
default: false
|
|
360
|
+
},
|
|
361
|
+
title: {
|
|
362
|
+
type: String,
|
|
363
|
+
default: "Input"
|
|
364
|
+
},
|
|
365
|
+
message: {
|
|
366
|
+
type: String,
|
|
367
|
+
default: ""
|
|
368
|
+
},
|
|
369
|
+
/** Initial / v-model text value. */
|
|
370
|
+
modelValue: {
|
|
371
|
+
type: String,
|
|
372
|
+
default: ""
|
|
373
|
+
},
|
|
374
|
+
placeholder: {
|
|
375
|
+
type: String,
|
|
376
|
+
default: ""
|
|
377
|
+
},
|
|
378
|
+
/** Returns an error message to block submit, or null/empty to allow it. */
|
|
379
|
+
validate: {
|
|
380
|
+
type: Function,
|
|
381
|
+
default: void 0
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
emits: [
|
|
385
|
+
"update:open",
|
|
386
|
+
"update:modelValue",
|
|
387
|
+
"submit",
|
|
388
|
+
"close"
|
|
389
|
+
],
|
|
390
|
+
setup(props, { emit }) {
|
|
391
|
+
const theme = useTheme();
|
|
392
|
+
const text = ref(props.modelValue);
|
|
393
|
+
watch(() => props.open, (open) => {
|
|
394
|
+
if (open) text.value = props.modelValue;
|
|
395
|
+
});
|
|
396
|
+
const error = computed(() => props.validate?.(text.value) ?? null);
|
|
397
|
+
function onInput(v) {
|
|
398
|
+
text.value = v;
|
|
399
|
+
emit("update:modelValue", v);
|
|
400
|
+
}
|
|
401
|
+
function submit() {
|
|
402
|
+
if (error.value) return;
|
|
403
|
+
emit("submit", text.value);
|
|
404
|
+
emit("update:open", false);
|
|
405
|
+
emit("close");
|
|
406
|
+
}
|
|
407
|
+
return () => h(VuiDialog, {
|
|
408
|
+
open: props.open,
|
|
409
|
+
title: props.title,
|
|
410
|
+
size: "medium",
|
|
411
|
+
autofocus: false,
|
|
412
|
+
"onUpdate:open": (v) => emit("update:open", v),
|
|
413
|
+
onClose: () => emit("close")
|
|
414
|
+
}, () => {
|
|
415
|
+
const rows = [];
|
|
416
|
+
if (props.message) {
|
|
417
|
+
rows.push(h("text", {
|
|
418
|
+
fg: theme.text,
|
|
419
|
+
wrap: "word"
|
|
420
|
+
}, props.message));
|
|
421
|
+
rows.push(h("text", {}, " "));
|
|
422
|
+
}
|
|
423
|
+
rows.push(h("box", {
|
|
424
|
+
border: "rounded",
|
|
425
|
+
borderColor: error.value ? theme.error : theme.border,
|
|
426
|
+
padding: {
|
|
427
|
+
left: 1,
|
|
428
|
+
right: 1
|
|
429
|
+
}
|
|
430
|
+
}, h(VuiInput, {
|
|
431
|
+
value: text.value,
|
|
432
|
+
placeholder: props.placeholder,
|
|
433
|
+
focused: true,
|
|
434
|
+
cursorColor: theme.primary,
|
|
435
|
+
"onUpdate:value": onInput,
|
|
436
|
+
onEnter: submit
|
|
437
|
+
})));
|
|
438
|
+
rows.push(error.value ? h("text", { fg: theme.error }, props.validate ? error.value : "") : h("text", { fg: theme.textMuted }, [
|
|
439
|
+
h("span", {
|
|
440
|
+
fg: theme.primary,
|
|
441
|
+
bold: true
|
|
442
|
+
}, "Enter"),
|
|
443
|
+
" submit · ",
|
|
444
|
+
h("span", {
|
|
445
|
+
fg: theme.primary,
|
|
446
|
+
bold: true
|
|
447
|
+
}, "Esc"),
|
|
448
|
+
" cancel"
|
|
449
|
+
]));
|
|
450
|
+
return rows;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/dialog-confirm.ts
|
|
456
|
+
const VuiDialogConfirm = defineComponent({
|
|
457
|
+
name: "VuiDialogConfirm",
|
|
458
|
+
props: {
|
|
459
|
+
open: {
|
|
460
|
+
type: Boolean,
|
|
461
|
+
default: false
|
|
462
|
+
},
|
|
463
|
+
title: {
|
|
464
|
+
type: String,
|
|
465
|
+
default: "Confirm"
|
|
466
|
+
},
|
|
467
|
+
message: {
|
|
468
|
+
type: String,
|
|
469
|
+
default: "Are you sure?"
|
|
470
|
+
},
|
|
471
|
+
confirmLabel: {
|
|
472
|
+
type: String,
|
|
473
|
+
default: "Yes"
|
|
474
|
+
},
|
|
475
|
+
cancelLabel: {
|
|
476
|
+
type: String,
|
|
477
|
+
default: "No"
|
|
478
|
+
},
|
|
479
|
+
/** Which choice is highlighted when the dialog opens. */
|
|
480
|
+
defaultConfirm: {
|
|
481
|
+
type: Boolean,
|
|
482
|
+
default: true
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
emits: [
|
|
486
|
+
"update:open",
|
|
487
|
+
"confirm",
|
|
488
|
+
"close"
|
|
489
|
+
],
|
|
490
|
+
setup(props, { emit }) {
|
|
491
|
+
const theme = useTheme();
|
|
492
|
+
const confirmActive = ref(props.defaultConfirm);
|
|
493
|
+
watch(() => props.open, (open) => {
|
|
494
|
+
if (open) confirmActive.value = props.defaultConfirm;
|
|
495
|
+
});
|
|
496
|
+
function decide(value) {
|
|
497
|
+
emit("confirm", value);
|
|
498
|
+
emit("update:open", false);
|
|
499
|
+
emit("close");
|
|
500
|
+
}
|
|
501
|
+
function onKeyDown(ev) {
|
|
502
|
+
if (ev.type !== "key") return;
|
|
503
|
+
switch (ev.name) {
|
|
504
|
+
case "left":
|
|
505
|
+
case "right":
|
|
506
|
+
case "tab":
|
|
507
|
+
ev.preventDefault();
|
|
508
|
+
confirmActive.value = !confirmActive.value;
|
|
509
|
+
break;
|
|
510
|
+
case "y":
|
|
511
|
+
ev.preventDefault();
|
|
512
|
+
decide(true);
|
|
513
|
+
break;
|
|
514
|
+
case "n":
|
|
515
|
+
ev.preventDefault();
|
|
516
|
+
decide(false);
|
|
517
|
+
break;
|
|
518
|
+
case "enter":
|
|
519
|
+
ev.preventDefault();
|
|
520
|
+
decide(confirmActive.value);
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function choice(label, active) {
|
|
525
|
+
return h("text", {
|
|
526
|
+
bg: active ? theme.primary : theme.backgroundElement,
|
|
527
|
+
fg: active ? theme.selectedText : theme.text,
|
|
528
|
+
padding: {
|
|
529
|
+
left: 2,
|
|
530
|
+
right: 2
|
|
531
|
+
}
|
|
532
|
+
}, label);
|
|
533
|
+
}
|
|
534
|
+
return () => h(VuiDialog, {
|
|
535
|
+
open: props.open,
|
|
536
|
+
title: props.title,
|
|
537
|
+
size: "small",
|
|
538
|
+
"onUpdate:open": (v) => emit("update:open", v),
|
|
539
|
+
onClose: () => emit("close"),
|
|
540
|
+
onKeyDown
|
|
541
|
+
}, () => [
|
|
542
|
+
h("text", {
|
|
543
|
+
fg: theme.text,
|
|
544
|
+
wrap: "word"
|
|
545
|
+
}, props.message),
|
|
546
|
+
h("text", {}, " "),
|
|
547
|
+
h("box", {
|
|
548
|
+
flexDirection: "row",
|
|
549
|
+
gap: 2,
|
|
550
|
+
justifyContent: "flex-end"
|
|
551
|
+
}, [choice(props.confirmLabel, confirmActive.value), choice(props.cancelLabel, !confirmActive.value)])
|
|
552
|
+
]);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/dialog-alert.ts
|
|
557
|
+
const VuiDialogAlert = defineComponent({
|
|
558
|
+
name: "VuiDialogAlert",
|
|
559
|
+
props: {
|
|
560
|
+
open: {
|
|
561
|
+
type: Boolean,
|
|
562
|
+
default: false
|
|
563
|
+
},
|
|
564
|
+
title: {
|
|
565
|
+
type: String,
|
|
566
|
+
default: "Alert"
|
|
567
|
+
},
|
|
568
|
+
message: {
|
|
569
|
+
type: String,
|
|
570
|
+
default: ""
|
|
571
|
+
},
|
|
572
|
+
okLabel: {
|
|
573
|
+
type: String,
|
|
574
|
+
default: "OK"
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
emits: ["update:open", "close"],
|
|
578
|
+
setup(props, { emit }) {
|
|
579
|
+
const theme = useTheme();
|
|
580
|
+
function close() {
|
|
581
|
+
emit("update:open", false);
|
|
582
|
+
emit("close");
|
|
583
|
+
}
|
|
584
|
+
function onKeyDown(ev) {
|
|
585
|
+
if (ev.type !== "key") return;
|
|
586
|
+
if (ev.name === "enter" || ev.name === "space") {
|
|
587
|
+
ev.preventDefault();
|
|
588
|
+
close();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return () => h(VuiDialog, {
|
|
592
|
+
open: props.open,
|
|
593
|
+
title: props.title,
|
|
594
|
+
size: "small",
|
|
595
|
+
"onUpdate:open": (v) => emit("update:open", v),
|
|
596
|
+
onClose: () => emit("close"),
|
|
597
|
+
onKeyDown
|
|
598
|
+
}, () => [
|
|
599
|
+
h("text", {
|
|
600
|
+
fg: theme.text,
|
|
601
|
+
wrap: "word"
|
|
602
|
+
}, props.message),
|
|
603
|
+
h("text", {}, " "),
|
|
604
|
+
h("text", { fg: theme.textMuted }, [h("span", {
|
|
605
|
+
fg: theme.primary,
|
|
606
|
+
bold: true
|
|
607
|
+
}, "Enter"), ` ${props.okLabel}`])
|
|
608
|
+
]);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/command-palette.ts
|
|
613
|
+
const VuiCommandPalette = defineComponent({
|
|
614
|
+
name: "VuiCommandPalette",
|
|
615
|
+
props: {
|
|
616
|
+
open: {
|
|
617
|
+
type: Boolean,
|
|
618
|
+
default: false
|
|
619
|
+
},
|
|
620
|
+
commands: {
|
|
621
|
+
type: Array,
|
|
622
|
+
default: () => []
|
|
623
|
+
},
|
|
624
|
+
title: {
|
|
625
|
+
type: String,
|
|
626
|
+
default: "Commands"
|
|
627
|
+
},
|
|
628
|
+
placeholder: {
|
|
629
|
+
type: String,
|
|
630
|
+
default: "Type a command…"
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
emits: [
|
|
634
|
+
"update:open",
|
|
635
|
+
"run",
|
|
636
|
+
"close"
|
|
637
|
+
],
|
|
638
|
+
setup(props, { emit }) {
|
|
639
|
+
const items = computed(() => props.commands.map((c) => ({
|
|
640
|
+
label: c.title,
|
|
641
|
+
value: c.id,
|
|
642
|
+
group: c.group,
|
|
643
|
+
hint: c.hint
|
|
644
|
+
})));
|
|
645
|
+
function onSelect(id) {
|
|
646
|
+
const cmd = props.commands.find((c) => c.id === id);
|
|
647
|
+
if (!cmd) return;
|
|
648
|
+
cmd.run?.();
|
|
649
|
+
emit("run", cmd);
|
|
650
|
+
}
|
|
651
|
+
return () => h(VuiDialogSelect, {
|
|
652
|
+
open: props.open,
|
|
653
|
+
title: props.title,
|
|
654
|
+
placeholder: props.placeholder,
|
|
655
|
+
items: items.value,
|
|
656
|
+
"onUpdate:open": (v) => emit("update:open", v),
|
|
657
|
+
onSelect,
|
|
658
|
+
onClose: () => emit("close")
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/toast.ts
|
|
664
|
+
const ToastSymbol = Symbol("vui.toasts");
|
|
665
|
+
/** Create + provide the toast controller. Call once in your root component setup. */
|
|
666
|
+
function provideToasts() {
|
|
667
|
+
const toasts = reactive([]);
|
|
668
|
+
let nextId = 1;
|
|
669
|
+
const controller = {
|
|
670
|
+
toasts,
|
|
671
|
+
show(message, opts) {
|
|
672
|
+
const id = nextId++;
|
|
673
|
+
toasts.push({
|
|
674
|
+
id,
|
|
675
|
+
message,
|
|
676
|
+
kind: opts?.kind ?? "info",
|
|
677
|
+
duration: opts?.duration ?? 4e3
|
|
678
|
+
});
|
|
679
|
+
return id;
|
|
680
|
+
},
|
|
681
|
+
dismiss(id) {
|
|
682
|
+
const at = toasts.findIndex((t) => t.id === id);
|
|
683
|
+
if (at >= 0) toasts.splice(at, 1);
|
|
684
|
+
},
|
|
685
|
+
clear() {
|
|
686
|
+
toasts.splice(0, toasts.length);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
provide(ToastSymbol, controller);
|
|
690
|
+
return controller;
|
|
691
|
+
}
|
|
692
|
+
/** Access the toast controller installed by `provideToasts()`. */
|
|
693
|
+
function useToast() {
|
|
694
|
+
const c = inject(ToastSymbol, null);
|
|
695
|
+
if (!c) throw new Error("useToast() requires provideToasts() in an ancestor");
|
|
696
|
+
return c;
|
|
697
|
+
}
|
|
698
|
+
/** Linear mix of two packed 0xRRGGBBAA colors (t=0 → a, t=1 → b). */
|
|
699
|
+
function mix(a, b, t) {
|
|
700
|
+
const ch = (shift) => {
|
|
701
|
+
const av = a >>> shift & 255;
|
|
702
|
+
const bv = b >>> shift & 255;
|
|
703
|
+
return Math.round(av + (bv - av) * t) & 255;
|
|
704
|
+
};
|
|
705
|
+
return (ch(24) << 24 | ch(16) << 16 | ch(8) << 8 | a & 255) >>> 0;
|
|
706
|
+
}
|
|
707
|
+
/** One toast row; owns its auto-dismiss tween + fade. */
|
|
708
|
+
const ToastItem = defineComponent({
|
|
709
|
+
name: "ToastItem",
|
|
710
|
+
props: { toast: {
|
|
711
|
+
type: Object,
|
|
712
|
+
required: true
|
|
713
|
+
} },
|
|
714
|
+
emits: ["dismiss"],
|
|
715
|
+
setup(props, { emit }) {
|
|
716
|
+
const theme = useTheme();
|
|
717
|
+
const timeline = useTimeline();
|
|
718
|
+
const fade = shallowRef(1);
|
|
719
|
+
if (props.toast.duration > 0) timeline.animate({
|
|
720
|
+
from: 0,
|
|
721
|
+
to: 1,
|
|
722
|
+
duration: props.toast.duration,
|
|
723
|
+
easing: "linear",
|
|
724
|
+
onUpdate: (p) => {
|
|
725
|
+
fade.value = p < .8 ? 1 : Math.max(0, 1 - (p - .8) / .2);
|
|
726
|
+
},
|
|
727
|
+
onComplete: () => emit("dismiss", props.toast.id)
|
|
728
|
+
});
|
|
729
|
+
const accent = () => {
|
|
730
|
+
const k = props.toast.kind;
|
|
731
|
+
return k === "success" ? theme.success : k === "warning" ? theme.warning : k === "error" ? theme.error : theme.info;
|
|
732
|
+
};
|
|
733
|
+
return () => {
|
|
734
|
+
const t = fade.value;
|
|
735
|
+
const bg = theme.backgroundPanel;
|
|
736
|
+
return h("box", {
|
|
737
|
+
border: "rounded",
|
|
738
|
+
borderColor: mix(bg, accent(), t),
|
|
739
|
+
bg,
|
|
740
|
+
padding: {
|
|
741
|
+
left: 1,
|
|
742
|
+
right: 1
|
|
743
|
+
},
|
|
744
|
+
margin: { top: 1 },
|
|
745
|
+
minWidth: 24,
|
|
746
|
+
maxWidth: 48
|
|
747
|
+
}, h("text", {
|
|
748
|
+
fg: mix(bg, theme.text, t),
|
|
749
|
+
wrap: "word"
|
|
750
|
+
}, [h("span", {
|
|
751
|
+
fg: mix(bg, accent(), t),
|
|
752
|
+
bold: true
|
|
753
|
+
}, `${ICON[props.toast.kind]} `), props.toast.message]));
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
const ICON = {
|
|
758
|
+
info: "ℹ",
|
|
759
|
+
success: "✔",
|
|
760
|
+
warning: "⚠",
|
|
761
|
+
error: "✖"
|
|
762
|
+
};
|
|
763
|
+
const VuiToastHost = defineComponent({
|
|
764
|
+
name: "VuiToastHost",
|
|
765
|
+
props: {
|
|
766
|
+
/** Corner to stack toasts in. */
|
|
767
|
+
position: {
|
|
768
|
+
type: String,
|
|
769
|
+
default: "top-right"
|
|
770
|
+
} },
|
|
771
|
+
setup(props) {
|
|
772
|
+
const controller = useToast();
|
|
773
|
+
const align = computed(() => {
|
|
774
|
+
const top = props.position.startsWith("top");
|
|
775
|
+
const right = props.position.endsWith("right");
|
|
776
|
+
return {
|
|
777
|
+
justifyContent: top ? "flex-start" : "flex-end",
|
|
778
|
+
alignItems: right ? "flex-end" : "flex-start"
|
|
779
|
+
};
|
|
780
|
+
});
|
|
781
|
+
return () => h("overlay", {
|
|
782
|
+
trapFocus: false,
|
|
783
|
+
padding: {
|
|
784
|
+
left: 2,
|
|
785
|
+
right: 2,
|
|
786
|
+
top: 1,
|
|
787
|
+
bottom: 1
|
|
788
|
+
},
|
|
789
|
+
flexDirection: "column",
|
|
790
|
+
justifyContent: align.value.justifyContent,
|
|
791
|
+
alignItems: align.value.alignItems
|
|
792
|
+
}, controller.toasts.map((toast) => h(ToastItem, {
|
|
793
|
+
key: toast.id,
|
|
794
|
+
toast,
|
|
795
|
+
onDismiss: (id) => controller.dismiss(id)
|
|
796
|
+
})));
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
//#endregion
|
|
800
|
+
//#region src/autocomplete.ts
|
|
801
|
+
/** Wire provider-stack suggestions + keyboard navigation for an input. */
|
|
802
|
+
function useAutocomplete(opts) {
|
|
803
|
+
const active = ref(0);
|
|
804
|
+
const suggestions = computed(() => {
|
|
805
|
+
const q = opts.query();
|
|
806
|
+
const merged = [];
|
|
807
|
+
for (const provider of opts.providers) for (const s of provider(q)) {
|
|
808
|
+
merged.push(s);
|
|
809
|
+
if (opts.max && merged.length >= opts.max) return merged;
|
|
810
|
+
}
|
|
811
|
+
return merged;
|
|
812
|
+
});
|
|
813
|
+
const visible = computed(() => suggestions.value.length > 0);
|
|
814
|
+
watch(suggestions, (s) => {
|
|
815
|
+
if (active.value > s.length - 1) active.value = Math.max(0, s.length - 1);
|
|
816
|
+
});
|
|
817
|
+
function move(delta) {
|
|
818
|
+
const n = suggestions.value.length;
|
|
819
|
+
if (n === 0) return;
|
|
820
|
+
active.value = (active.value + delta + n) % n;
|
|
821
|
+
}
|
|
822
|
+
function accept() {
|
|
823
|
+
const s = suggestions.value[active.value];
|
|
824
|
+
if (s) opts.onAccept(s);
|
|
825
|
+
}
|
|
826
|
+
function onKeyDown(ev) {
|
|
827
|
+
if (ev.type !== "key" || !visible.value) return;
|
|
828
|
+
if (ev.name === "up") {
|
|
829
|
+
ev.preventDefault();
|
|
830
|
+
move(-1);
|
|
831
|
+
} else if (ev.name === "down") {
|
|
832
|
+
ev.preventDefault();
|
|
833
|
+
move(1);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
suggestions,
|
|
838
|
+
active,
|
|
839
|
+
visible,
|
|
840
|
+
onKeyDown,
|
|
841
|
+
accept
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const VuiAutocomplete = defineComponent({
|
|
845
|
+
name: "VuiAutocomplete",
|
|
846
|
+
props: {
|
|
847
|
+
suggestions: {
|
|
848
|
+
type: Array,
|
|
849
|
+
default: () => []
|
|
850
|
+
},
|
|
851
|
+
active: {
|
|
852
|
+
type: Number,
|
|
853
|
+
default: 0
|
|
854
|
+
},
|
|
855
|
+
maxRows: {
|
|
856
|
+
type: Number,
|
|
857
|
+
default: 8
|
|
858
|
+
}
|
|
859
|
+
},
|
|
860
|
+
emits: ["select"],
|
|
861
|
+
setup(props, { emit }) {
|
|
862
|
+
const theme = useTheme();
|
|
863
|
+
const shown = computed(() => props.suggestions.slice(0, props.maxRows));
|
|
864
|
+
return () => {
|
|
865
|
+
if (props.suggestions.length === 0) return null;
|
|
866
|
+
return h("box", {
|
|
867
|
+
flexDirection: "column",
|
|
868
|
+
border: "rounded",
|
|
869
|
+
borderColor: theme.border,
|
|
870
|
+
bg: theme.backgroundMenu,
|
|
871
|
+
alignSelf: "flex-start",
|
|
872
|
+
minWidth: 20
|
|
873
|
+
}, shown.value.map((s, i) => {
|
|
874
|
+
const on = i === props.active;
|
|
875
|
+
return h("box", {
|
|
876
|
+
key: s.value,
|
|
877
|
+
flexDirection: "row",
|
|
878
|
+
justifyContent: "space-between",
|
|
879
|
+
gap: 2,
|
|
880
|
+
bg: on ? theme.primary : void 0,
|
|
881
|
+
padding: {
|
|
882
|
+
left: 1,
|
|
883
|
+
right: 1
|
|
884
|
+
},
|
|
885
|
+
onMouseDown: (ev) => {
|
|
886
|
+
ev.preventDefault();
|
|
887
|
+
emit("select", s, i);
|
|
888
|
+
}
|
|
889
|
+
}, [h("text", { fg: on ? theme.selectedText : theme.text }, s.label), s.hint ? h("text", { fg: on ? theme.selectedText : theme.textMuted }, s.hint) : null]);
|
|
890
|
+
}));
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/status-bar.ts
|
|
896
|
+
const VuiStatusBar = defineComponent({
|
|
897
|
+
name: "VuiStatusBar",
|
|
898
|
+
props: {
|
|
899
|
+
height: {
|
|
900
|
+
type: Number,
|
|
901
|
+
default: 1
|
|
902
|
+
},
|
|
903
|
+
bg: {
|
|
904
|
+
type: [String, Number],
|
|
905
|
+
default: void 0
|
|
906
|
+
},
|
|
907
|
+
fg: {
|
|
908
|
+
type: [String, Number],
|
|
909
|
+
default: void 0
|
|
910
|
+
},
|
|
911
|
+
/** Horizontal padding inside the bar. */
|
|
912
|
+
pad: {
|
|
913
|
+
type: Number,
|
|
914
|
+
default: 1
|
|
915
|
+
}
|
|
916
|
+
},
|
|
917
|
+
setup(props, { slots }) {
|
|
918
|
+
const theme = useTheme();
|
|
919
|
+
return () => h("box", {
|
|
920
|
+
width: { pct: 1 },
|
|
921
|
+
height: props.height,
|
|
922
|
+
flexDirection: "row",
|
|
923
|
+
alignItems: "center",
|
|
924
|
+
justifyContent: "space-between",
|
|
925
|
+
bg: props.bg ?? theme.backgroundPanel,
|
|
926
|
+
fg: props.fg ?? theme.textMuted,
|
|
927
|
+
padding: {
|
|
928
|
+
left: props.pad,
|
|
929
|
+
right: props.pad
|
|
930
|
+
}
|
|
931
|
+
}, [
|
|
932
|
+
h("box", {
|
|
933
|
+
flexDirection: "row",
|
|
934
|
+
alignItems: "center",
|
|
935
|
+
gap: 1
|
|
936
|
+
}, slots.left?.()),
|
|
937
|
+
h("box", {
|
|
938
|
+
flexDirection: "row",
|
|
939
|
+
alignItems: "center",
|
|
940
|
+
gap: 1
|
|
941
|
+
}, slots.center?.() ?? slots.default?.()),
|
|
942
|
+
h("box", {
|
|
943
|
+
flexDirection: "row",
|
|
944
|
+
alignItems: "center",
|
|
945
|
+
gap: 1
|
|
946
|
+
}, slots.right?.())
|
|
947
|
+
]);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
/** A header row: a status bar with the active-border accent by default. */
|
|
951
|
+
const VuiHeader = defineComponent({
|
|
952
|
+
name: "VuiHeader",
|
|
953
|
+
props: {
|
|
954
|
+
bg: {
|
|
955
|
+
type: [String, Number],
|
|
956
|
+
default: void 0
|
|
957
|
+
},
|
|
958
|
+
fg: {
|
|
959
|
+
type: [String, Number],
|
|
960
|
+
default: void 0
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
setup(props, { slots }) {
|
|
964
|
+
const theme = useTheme();
|
|
965
|
+
return () => h(VuiStatusBar, {
|
|
966
|
+
bg: props.bg ?? theme.backgroundElement,
|
|
967
|
+
fg: props.fg ?? theme.text
|
|
968
|
+
}, slots);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
/** A footer row — alias of the status bar with footer-typical muted styling. */
|
|
972
|
+
const VuiFooter = defineComponent({
|
|
973
|
+
name: "VuiFooter",
|
|
974
|
+
setup(_props, { slots }) {
|
|
975
|
+
return () => h(VuiStatusBar, null, slots);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/virtual-list.ts
|
|
980
|
+
function clamp(v, lo, hi) {
|
|
981
|
+
return Math.max(lo, Math.min(v, hi));
|
|
982
|
+
}
|
|
983
|
+
const MAX_WINDOW = 500;
|
|
984
|
+
const VuiVirtualList = defineComponent({
|
|
985
|
+
name: "VuiVirtualList",
|
|
986
|
+
inheritAttrs: false,
|
|
987
|
+
props: {
|
|
988
|
+
items: {
|
|
989
|
+
type: Array,
|
|
990
|
+
required: true
|
|
991
|
+
},
|
|
992
|
+
/** Viewport height in rows (definite — see the note above). */
|
|
993
|
+
height: {
|
|
994
|
+
type: Number,
|
|
995
|
+
required: true
|
|
996
|
+
},
|
|
997
|
+
/** Rows each item occupies. */
|
|
998
|
+
itemHeight: {
|
|
999
|
+
type: Number,
|
|
1000
|
+
default: 1
|
|
1001
|
+
},
|
|
1002
|
+
/** Extra rows mounted above/below the viewport to smooth fast scrolling. */
|
|
1003
|
+
overscan: {
|
|
1004
|
+
type: Number,
|
|
1005
|
+
default: 2
|
|
1006
|
+
},
|
|
1007
|
+
focused: {
|
|
1008
|
+
type: Boolean,
|
|
1009
|
+
default: false
|
|
1010
|
+
},
|
|
1011
|
+
focusable: {
|
|
1012
|
+
type: Boolean,
|
|
1013
|
+
default: true
|
|
1014
|
+
},
|
|
1015
|
+
/** Scroll step (rows) for arrow keys / one wheel notch. */
|
|
1016
|
+
step: {
|
|
1017
|
+
type: Number,
|
|
1018
|
+
default: 1
|
|
1019
|
+
},
|
|
1020
|
+
/** Render an integrated vertical scrollbar (indicator + drag) on the right edge. */
|
|
1021
|
+
scrollbar: {
|
|
1022
|
+
type: Boolean,
|
|
1023
|
+
default: false
|
|
1024
|
+
},
|
|
1025
|
+
/**
|
|
1026
|
+
* Controlled scroll offset (top row). Bind it (`v-model:scrollY` /
|
|
1027
|
+
* `:scrollY` + `@update:scrollY`) to drive the list from an ancestor — e.g. a
|
|
1028
|
+
* focused parent that owns the keyboard. Omit for uncontrolled (internal).
|
|
1029
|
+
*/
|
|
1030
|
+
scrollY: {
|
|
1031
|
+
type: Number,
|
|
1032
|
+
default: void 0
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
emits: ["scroll", "update:scrollY"],
|
|
1036
|
+
setup(props, { attrs, emit, slots }) {
|
|
1037
|
+
const localScrollY = ref(0);
|
|
1038
|
+
const viewRows = computed(() => Math.max(1, props.height));
|
|
1039
|
+
const totalRows = computed(() => props.items.length * props.itemHeight);
|
|
1040
|
+
const maxScroll = computed(() => Math.max(0, totalRows.value - viewRows.value));
|
|
1041
|
+
const scrollPos = computed(() => clamp(props.scrollY ?? localScrollY.value, 0, maxScroll.value));
|
|
1042
|
+
const window = computed(() => {
|
|
1043
|
+
const ih = Math.max(1, props.itemHeight);
|
|
1044
|
+
const first = Math.max(0, Math.floor(scrollPos.value / ih) - props.overscan);
|
|
1045
|
+
const visible = Math.min(MAX_WINDOW, Math.ceil(viewRows.value / ih) + props.overscan * 2);
|
|
1046
|
+
return {
|
|
1047
|
+
first,
|
|
1048
|
+
last: Math.min(props.items.length, first + visible)
|
|
1049
|
+
};
|
|
1050
|
+
});
|
|
1051
|
+
function scrollTo(rows) {
|
|
1052
|
+
const next = clamp(Math.round(rows), 0, maxScroll.value);
|
|
1053
|
+
if (next === scrollPos.value) return;
|
|
1054
|
+
localScrollY.value = next;
|
|
1055
|
+
emit("update:scrollY", next);
|
|
1056
|
+
emit("scroll", next);
|
|
1057
|
+
}
|
|
1058
|
+
function onKeyDown(ev) {
|
|
1059
|
+
if (ev.type !== "key") return;
|
|
1060
|
+
const page = Math.max(1, viewRows.value - 1);
|
|
1061
|
+
const d = {
|
|
1062
|
+
up: -props.step,
|
|
1063
|
+
down: props.step,
|
|
1064
|
+
pageUp: -page,
|
|
1065
|
+
pageDown: page,
|
|
1066
|
+
home: -scrollPos.value,
|
|
1067
|
+
end: maxScroll.value - scrollPos.value
|
|
1068
|
+
}[ev.name];
|
|
1069
|
+
if (d !== void 0) {
|
|
1070
|
+
ev.preventDefault();
|
|
1071
|
+
scrollTo(scrollPos.value + d);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function onWheel(ev) {
|
|
1075
|
+
if (ev.type !== "mouse" || ev.kind !== "wheel") return;
|
|
1076
|
+
ev.preventDefault();
|
|
1077
|
+
scrollTo(scrollPos.value + (ev.button === "wheelUp" ? -props.step : props.step) * 3);
|
|
1078
|
+
}
|
|
1079
|
+
return () => {
|
|
1080
|
+
const { first, last } = window.value;
|
|
1081
|
+
const ih = Math.max(1, props.itemHeight);
|
|
1082
|
+
const topPad = first * ih;
|
|
1083
|
+
const bottomPad = Math.max(0, totalRows.value - last * ih);
|
|
1084
|
+
const rows = [];
|
|
1085
|
+
if (topPad > 0) rows.push(h("box", {
|
|
1086
|
+
key: "vl-top",
|
|
1087
|
+
height: topPad,
|
|
1088
|
+
flexShrink: 0
|
|
1089
|
+
}));
|
|
1090
|
+
for (let i = first; i < last; i++) rows.push(h("box", {
|
|
1091
|
+
key: `vl-${i}`,
|
|
1092
|
+
height: ih,
|
|
1093
|
+
flexShrink: 0
|
|
1094
|
+
}, slots.default?.({
|
|
1095
|
+
item: props.items[i],
|
|
1096
|
+
index: i
|
|
1097
|
+
})));
|
|
1098
|
+
if (bottomPad > 0) rows.push(h("box", {
|
|
1099
|
+
key: "vl-bot",
|
|
1100
|
+
height: bottomPad,
|
|
1101
|
+
flexShrink: 0
|
|
1102
|
+
}));
|
|
1103
|
+
const viewport = h("box", {
|
|
1104
|
+
...props.scrollbar ? { flexGrow: 1 } : attrs,
|
|
1105
|
+
height: props.height,
|
|
1106
|
+
flexDirection: "column",
|
|
1107
|
+
overflow: "scroll",
|
|
1108
|
+
scrollY: scrollPos.value,
|
|
1109
|
+
focusable: props.focusable,
|
|
1110
|
+
focused: props.focused,
|
|
1111
|
+
onKeyDown,
|
|
1112
|
+
onWheel
|
|
1113
|
+
}, rows);
|
|
1114
|
+
if (!props.scrollbar) return viewport;
|
|
1115
|
+
return h("box", {
|
|
1116
|
+
...attrs,
|
|
1117
|
+
height: props.height,
|
|
1118
|
+
flexDirection: "row"
|
|
1119
|
+
}, [viewport, h(VuiScrollBar, {
|
|
1120
|
+
scrollY: scrollPos.value,
|
|
1121
|
+
viewportHeight: viewRows.value,
|
|
1122
|
+
contentHeight: totalRows.value,
|
|
1123
|
+
"onUpdate:scrollY": (y) => scrollTo(y),
|
|
1124
|
+
onWheel
|
|
1125
|
+
})]);
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region src/working-indicator.ts
|
|
1131
|
+
const VuiWorkingIndicator = defineComponent({
|
|
1132
|
+
name: "VuiWorkingIndicator",
|
|
1133
|
+
props: {
|
|
1134
|
+
label: {
|
|
1135
|
+
type: String,
|
|
1136
|
+
default: "Working…"
|
|
1137
|
+
},
|
|
1138
|
+
done: {
|
|
1139
|
+
type: Boolean,
|
|
1140
|
+
default: false
|
|
1141
|
+
},
|
|
1142
|
+
doneLabel: {
|
|
1143
|
+
type: String,
|
|
1144
|
+
default: "Done"
|
|
1145
|
+
},
|
|
1146
|
+
preset: {
|
|
1147
|
+
type: String,
|
|
1148
|
+
default: "braille"
|
|
1149
|
+
},
|
|
1150
|
+
/** Spinner / check color; defaults to theme accent (busy) / success (done). */
|
|
1151
|
+
color: {
|
|
1152
|
+
type: [String, Number],
|
|
1153
|
+
default: void 0
|
|
1154
|
+
},
|
|
1155
|
+
/** Glyph shown when done. */
|
|
1156
|
+
doneGlyph: {
|
|
1157
|
+
type: String,
|
|
1158
|
+
default: "✔"
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
setup(props) {
|
|
1162
|
+
const theme = useTheme();
|
|
1163
|
+
return () => {
|
|
1164
|
+
if (props.done) return h("text", { fg: props.color ?? theme.success }, [h("span", { bold: true }, `${props.doneGlyph} `), props.doneLabel]);
|
|
1165
|
+
return h(VuiSpinner$1, {
|
|
1166
|
+
preset: props.preset,
|
|
1167
|
+
color: props.color ?? theme.accent,
|
|
1168
|
+
label: props.label
|
|
1169
|
+
});
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
//#endregion
|
|
1174
|
+
export { SPINNER_PRESETS, VuiAutocomplete, VuiCommandPalette, VuiDialog, VuiDialogAlert, VuiDialogConfirm, VuiDialogPrompt, VuiDialogSelect, VuiFooter, VuiHeader, VuiSpinner, VuiStatusBar, VuiToastHost, VuiVirtualList, VuiWorkingIndicator, fuzzyFilter, fuzzyMatch, provideToasts, useAutocomplete, useFocusTrap, useToast };
|