@viliha/vui-ui 1.0.7 → 1.0.9
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/button.tsx +1 -1
- package/src/command-palette.tsx +227 -0
- package/src/dropdown-menu.tsx +5 -1
- package/src/kbd.tsx +50 -0
- package/src/record-view.tsx +7 -10
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.9",
|
|
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",
|
package/src/button.tsx
CHANGED
|
@@ -23,7 +23,7 @@ const NEUTRAL =
|
|
|
23
23
|
const VARIANTS: Record<ButtonVariant, string> = {
|
|
24
24
|
default: NEUTRAL,
|
|
25
25
|
primary:
|
|
26
|
-
"border border-transparent bg-[var(--button-primary)] text-[var(--button-primary-foreground)] shadow-[var(--button-shadow)] hover:bg-[var(--button-primary-hover)] [&_svg]:border-
|
|
26
|
+
"border border-transparent bg-[var(--button-primary)] text-[var(--button-primary-foreground)] shadow-[var(--button-shadow)] hover:bg-[var(--button-primary-hover)] [&_svg]:border-[var(--button-primary-hover)]",
|
|
27
27
|
secondary: NEUTRAL,
|
|
28
28
|
outline: NEUTRAL,
|
|
29
29
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
@@ -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/dropdown-menu.tsx
CHANGED
|
@@ -14,6 +14,9 @@ interface DropdownProps {
|
|
|
14
14
|
ariaLabel?: string;
|
|
15
15
|
/** Render the trigger as a compact toolbar button (default) or a plain one. */
|
|
16
16
|
active?: boolean;
|
|
17
|
+
/** Extra classes on the label text — e.g. `hidden sm:inline` to hide it on
|
|
18
|
+
* mobile so the trigger collapses to an icon-only button. */
|
|
19
|
+
labelClassName?: string;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/** Minimal click-to-open menu with outside-click + Escape to close. */
|
|
@@ -24,6 +27,7 @@ export function Dropdown({
|
|
|
24
27
|
children,
|
|
25
28
|
ariaLabel,
|
|
26
29
|
active,
|
|
30
|
+
labelClassName,
|
|
27
31
|
}: DropdownProps) {
|
|
28
32
|
const [open, setOpen] = React.useState(false);
|
|
29
33
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
@@ -62,7 +66,7 @@ export function Dropdown({
|
|
|
62
66
|
)}
|
|
63
67
|
>
|
|
64
68
|
{icon}
|
|
65
|
-
{label}
|
|
69
|
+
{label && <span className={cn("truncate", labelClassName)}>{label}</span>}
|
|
66
70
|
</button>
|
|
67
71
|
{open && (
|
|
68
72
|
<div
|
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
|
+
}
|
package/src/record-view.tsx
CHANGED
|
@@ -708,16 +708,6 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
708
708
|
<div className="flex h-12 items-center justify-between border-b border-border px-4">
|
|
709
709
|
<div className="flex items-center gap-2">{titleLeading}</div>
|
|
710
710
|
<div className="flex items-center gap-1.5">
|
|
711
|
-
<Button
|
|
712
|
-
variant="link"
|
|
713
|
-
size="sm"
|
|
714
|
-
onClick={addRow}
|
|
715
|
-
className="px-1 no-underline hover:no-underline"
|
|
716
|
-
>
|
|
717
|
-
<Plus className="size-4 text-emerald-500" />
|
|
718
|
-
<span>{singular}</span>
|
|
719
|
-
</Button>
|
|
720
|
-
|
|
721
711
|
<input
|
|
722
712
|
ref={importRef}
|
|
723
713
|
type="file"
|
|
@@ -728,6 +718,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
728
718
|
/>
|
|
729
719
|
<Dropdown
|
|
730
720
|
label="Import"
|
|
721
|
+
labelClassName="hidden sm:inline"
|
|
731
722
|
icon={<Upload className="size-3.5 text-sky-500" />}
|
|
732
723
|
align="end"
|
|
733
724
|
>
|
|
@@ -751,6 +742,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
751
742
|
|
|
752
743
|
<Dropdown
|
|
753
744
|
label="Export"
|
|
745
|
+
labelClassName="hidden sm:inline"
|
|
754
746
|
icon={<Download className="size-3.5 text-violet-500" />}
|
|
755
747
|
align="end"
|
|
756
748
|
>
|
|
@@ -790,6 +782,11 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
790
782
|
Show all columns
|
|
791
783
|
</DropdownItem>
|
|
792
784
|
</Dropdown>
|
|
785
|
+
|
|
786
|
+
<Button variant="primary" size="sm" onClick={addRow} className="ml-1">
|
|
787
|
+
<Plus className="size-4" />
|
|
788
|
+
<span className="hidden sm:inline">{singular}</span>
|
|
789
|
+
</Button>
|
|
793
790
|
</div>
|
|
794
791
|
</div>
|
|
795
792
|
|