@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/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 };