@viliha/vui-ui 1.0.7 → 1.0.8

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 CHANGED
@@ -60,7 +60,8 @@ export function Example() {
60
60
  ## Components
61
61
 
62
62
  `avatar` · `badge` · `button` · `card` · `chart` (themed Recharts wrapper) ·
63
- `checkbox` · `dialog` · `confirm-dialog` · `dropdown-menu` · `input` · `menu` ·
63
+ `checkbox` · `command-palette` (⌘K launcher) · `dialog` · `confirm-dialog` ·
64
+ `dropdown-menu` · `input` · `kbd` (key caps + `Shortcut`) · `menu` ·
64
65
  `required-mark` · `select` · `table` · `record-view` (the full datatable) · plus
65
66
  the `utils` (`cn`) helper and the `theme.css` design tokens.
66
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
5
  "license": "MIT",
6
6
  "author": "Suman Bonakurthi",
@@ -0,0 +1,227 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "./utils";
6
+ import { Kbd } from "./kbd";
7
+
8
+ /**
9
+ * A generic ⌘K-style command palette. Fully controlled and headless of any
10
+ * router — the host owns the open state, supplies the `actions`, and each
11
+ * action carries its own `onSelect` (navigate, open a modal, run a command…).
12
+ *
13
+ * ```tsx
14
+ * const [open, setOpen] = useState(false);
15
+ * <CommandPalette
16
+ * open={open}
17
+ * onClose={() => setOpen(false)}
18
+ * actions={[
19
+ * { id: "home", label: "Home", group: "Go to", onSelect: () => router.push("/") },
20
+ * ]}
21
+ * />
22
+ * ```
23
+ */
24
+ export type CommandAction = {
25
+ /** Stable unique key. */
26
+ id: string;
27
+ label: string;
28
+ /** Optional group heading; actions with the same group render together. */
29
+ group?: string;
30
+ /** Optional leading icon (anything that takes a `className`). */
31
+ icon?: React.ComponentType<{ className?: string }>;
32
+ /** Extra classes for the icon (e.g. a brand color). */
33
+ iconClassName?: string;
34
+ /** Extra text matched by the search, beyond `label` and `group`. */
35
+ keywords?: string;
36
+ /** Runs when the action is chosen (Enter or click). */
37
+ onSelect: () => void;
38
+ };
39
+
40
+ export function CommandPalette({
41
+ open,
42
+ onClose,
43
+ actions,
44
+ placeholder = "Search…",
45
+ emptyMessage,
46
+ }: {
47
+ open: boolean;
48
+ onClose: () => void;
49
+ actions: CommandAction[];
50
+ placeholder?: string;
51
+ /** Message when the search matches nothing; `{q}` in the default is the query. */
52
+ emptyMessage?: string;
53
+ }) {
54
+ const [q, setQ] = React.useState("");
55
+ const [active, setActive] = React.useState(0);
56
+ const listRef = React.useRef<HTMLDivElement>(null);
57
+ const dialogRef = React.useRef<HTMLDivElement>(null);
58
+ const inputRef = React.useRef<HTMLInputElement>(null);
59
+
60
+ // On open: reset, focus the search, and close on an outside click.
61
+ React.useEffect(() => {
62
+ if (!open) return;
63
+ setQ("");
64
+ setActive(0);
65
+ inputRef.current?.focus();
66
+ const onDown = (e: MouseEvent) => {
67
+ if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
68
+ onClose();
69
+ }
70
+ };
71
+ document.addEventListener("mousedown", onDown);
72
+ return () => document.removeEventListener("mousedown", onDown);
73
+ }, [open, onClose]);
74
+
75
+ const results = React.useMemo(() => {
76
+ const term = q.trim().toLowerCase();
77
+ if (!term) return actions;
78
+ return actions.filter(
79
+ (a) =>
80
+ a.label.toLowerCase().includes(term) ||
81
+ a.group?.toLowerCase().includes(term) ||
82
+ a.keywords?.toLowerCase().includes(term),
83
+ );
84
+ }, [q, actions]);
85
+
86
+ // Group filtered results while preserving order.
87
+ const groups = React.useMemo(() => {
88
+ const map = new Map<string, CommandAction[]>();
89
+ for (const a of results) {
90
+ const key = a.group ?? "";
91
+ const list = map.get(key);
92
+ if (list) list.push(a);
93
+ else map.set(key, [a]);
94
+ }
95
+ return [...map.entries()];
96
+ }, [results]);
97
+
98
+ // Clamp the highlight if the result set shrank.
99
+ React.useEffect(() => {
100
+ setActive((i) => (i >= results.length ? Math.max(results.length - 1, 0) : i));
101
+ }, [results.length]);
102
+
103
+ // Keep the highlighted row scrolled into view.
104
+ React.useEffect(() => {
105
+ listRef.current
106
+ ?.querySelector('[data-active="true"]')
107
+ ?.scrollIntoView({ block: "nearest" });
108
+ }, [active, open]);
109
+
110
+ const run = React.useCallback(
111
+ (a: CommandAction) => {
112
+ onClose();
113
+ a.onSelect();
114
+ },
115
+ [onClose],
116
+ );
117
+
118
+ const onKeyDown = (e: React.KeyboardEvent) => {
119
+ if (e.key === "ArrowDown") {
120
+ e.preventDefault();
121
+ setActive((i) => Math.min(i + 1, results.length - 1));
122
+ } else if (e.key === "ArrowUp") {
123
+ e.preventDefault();
124
+ setActive((i) => Math.max(i - 1, 0));
125
+ } else if (e.key === "Enter") {
126
+ e.preventDefault();
127
+ const a = results[active];
128
+ if (a) run(a);
129
+ } else if (e.key === "Escape") {
130
+ e.preventDefault();
131
+ onClose();
132
+ }
133
+ };
134
+
135
+ if (!open) return null;
136
+
137
+ let flatIndex = -1;
138
+ return (
139
+ <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/40 p-4 pt-[12vh]">
140
+ <div
141
+ ref={dialogRef}
142
+ role="dialog"
143
+ aria-modal="true"
144
+ aria-label="Command palette"
145
+ className="vui-pop-in flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
146
+ style={{ "--vui-pop-origin": "top center" } as React.CSSProperties}
147
+ >
148
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3">
149
+ <SearchGlyph />
150
+ <input
151
+ ref={inputRef}
152
+ value={q}
153
+ onChange={(e) => {
154
+ setQ(e.target.value);
155
+ setActive(0);
156
+ }}
157
+ onKeyDown={onKeyDown}
158
+ placeholder={placeholder}
159
+ aria-label={placeholder}
160
+ className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
161
+ />
162
+ <Kbd className="shrink-0">Esc</Kbd>
163
+ </div>
164
+
165
+ <div ref={listRef} className="min-h-0 flex-1 overflow-y-auto p-1.5">
166
+ {results.length === 0 ? (
167
+ <p className="px-3 py-8 text-center text-sm text-muted-foreground">
168
+ {emptyMessage ?? `No results for “${q}”.`}
169
+ </p>
170
+ ) : (
171
+ groups.map(([group, items]) => (
172
+ <div key={group || "_"} className="mb-1 last:mb-0">
173
+ {group && (
174
+ <p className="px-2 py-1 text-xs font-medium text-muted-foreground">
175
+ {group}
176
+ </p>
177
+ )}
178
+ {items.map((a) => {
179
+ flatIndex += 1;
180
+ const idx = flatIndex;
181
+ const Icon = a.icon;
182
+ return (
183
+ <button
184
+ key={a.id}
185
+ type="button"
186
+ data-active={idx === active}
187
+ onMouseMove={() => setActive(idx)}
188
+ onClick={() => run(a)}
189
+ className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
190
+ >
191
+ {Icon && (
192
+ <Icon
193
+ className={cn(
194
+ "size-4 shrink-0",
195
+ a.iconClassName ?? "text-muted-foreground",
196
+ )}
197
+ />
198
+ )}
199
+ <span className="flex-1 truncate">{a.label}</span>
200
+ </button>
201
+ );
202
+ })}
203
+ </div>
204
+ ))
205
+ )}
206
+ </div>
207
+ </div>
208
+ </div>
209
+ );
210
+ }
211
+
212
+ /** Inline magnifier so the palette carries no icon dependency. */
213
+ function SearchGlyph() {
214
+ return (
215
+ <svg
216
+ viewBox="0 0 15 15"
217
+ aria-hidden="true"
218
+ className="size-4 shrink-0 text-muted-foreground"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ strokeWidth="1.2"
222
+ >
223
+ <circle cx="6.5" cy="6.5" r="4" />
224
+ <path d="M9.5 9.5 L13 13" strokeLinecap="round" />
225
+ </svg>
226
+ );
227
+ }
package/src/kbd.tsx ADDED
@@ -0,0 +1,50 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ /** A single keyboard key cap. */
6
+ export function Kbd({
7
+ children,
8
+ className,
9
+ }: {
10
+ children: React.ReactNode;
11
+ className?: string;
12
+ }) {
13
+ return (
14
+ <kbd
15
+ className={cn(
16
+ "inline-grid min-w-[1.4rem] place-items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground",
17
+ className,
18
+ )}
19
+ >
20
+ {children}
21
+ </kbd>
22
+ );
23
+ }
24
+
25
+ /**
26
+ * A keyboard shortcut rendered as separate key caps joined by "+" — e.g.
27
+ * `<Shortcut keys={["⌘", "K"]} />` → ⌘ + K. Pass each key as its own string.
28
+ */
29
+ export function Shortcut({
30
+ keys,
31
+ className,
32
+ }: {
33
+ keys: string[];
34
+ className?: string;
35
+ }) {
36
+ return (
37
+ <span className={cn("inline-flex items-center gap-1", className)}>
38
+ {keys.map((k, i) => (
39
+ <React.Fragment key={`${k}-${i}`}>
40
+ {i > 0 && (
41
+ <span aria-hidden="true" className="text-muted-foreground/60">
42
+ +
43
+ </span>
44
+ )}
45
+ <Kbd>{k}</Kbd>
46
+ </React.Fragment>
47
+ ))}
48
+ </span>
49
+ );
50
+ }