@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 +2 -1
- package/package.json +1 -1
- package/src/command-palette.tsx +227 -0
- package/src/kbd.tsx +50 -0
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` · `
|
|
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.
|
|
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
|
+
}
|