@swift-rust/ui 0.3.0 → 0.6.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 +66 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +89 -41
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +36 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/components.d.ts.map +1 -1
- package/dist/components.js +61 -32
- package/dist/components.js.map +1 -1
- package/dist/registry.test.d.ts +2 -0
- package/dist/registry.test.d.ts.map +1 -0
- package/dist/registry.test.js +82 -0
- package/dist/registry.test.js.map +1 -0
- package/dist/smoke.test.js +5 -3
- package/dist/smoke.test.js.map +1 -1
- package/package.json +2 -2
- package/registry/components/accordion.tsx +125 -16
- package/registry/components/alert-dialog.tsx +102 -0
- package/registry/components/alert.tsx +114 -14
- package/registry/components/aspect-ratio.tsx +18 -0
- package/registry/components/avatar.tsx +59 -7
- package/registry/components/badge.tsx +29 -14
- package/registry/components/breadcrumb.tsx +7 -13
- package/registry/components/button-group.tsx +28 -0
- package/registry/components/button.tsx +113 -28
- package/registry/components/calendar.tsx +92 -0
- package/registry/components/callout.tsx +14 -14
- package/registry/components/card.tsx +87 -12
- package/registry/components/carousel.tsx +41 -0
- package/registry/components/chart.tsx +50 -0
- package/registry/components/checkbox.tsx +5 -5
- package/registry/components/code-block.tsx +118 -0
- package/registry/components/code.tsx +2 -3
- package/registry/components/collapsible.tsx +60 -0
- package/registry/components/combobox.tsx +102 -0
- package/registry/components/command.tsx +5 -5
- package/registry/components/context-menu.tsx +81 -0
- package/registry/components/data-table.tsx +71 -0
- package/registry/components/date-picker.tsx +58 -0
- package/registry/components/dialog.tsx +2 -2
- package/registry/components/direction.tsx +17 -0
- package/registry/components/drawer.tsx +77 -0
- package/registry/components/dropdown-menu.tsx +5 -5
- package/registry/components/empty.tsx +34 -0
- package/registry/components/field.tsx +27 -0
- package/registry/components/file-upload.tsx +116 -0
- package/registry/components/form.tsx +3 -4
- package/registry/components/hover-card.tsx +59 -0
- package/registry/components/input-group.tsx +34 -0
- package/registry/components/input-otp.tsx +50 -0
- package/registry/components/input.tsx +71 -7
- package/registry/components/item.tsx +42 -0
- package/registry/components/kbd.tsx +3 -4
- package/registry/components/label.tsx +34 -4
- package/registry/components/menubar.tsx +60 -0
- package/registry/components/native-select.tsx +35 -0
- package/registry/components/navigation-menu.tsx +3 -3
- package/registry/components/pagination.tsx +4 -5
- package/registry/components/popover.tsx +1 -1
- package/registry/components/progress.tsx +10 -5
- package/registry/components/radio-group.tsx +9 -9
- package/registry/components/resizable.tsx +77 -0
- package/registry/components/scroll-area.tsx +20 -0
- package/registry/components/select.tsx +2 -3
- package/registry/components/separator.tsx +1 -2
- package/registry/components/sheet.tsx +1 -1
- package/registry/components/sidebar.tsx +72 -0
- package/registry/components/skeleton.tsx +1 -6
- package/registry/components/slider.tsx +6 -3
- package/registry/components/sonner.tsx +52 -0
- package/registry/components/spinner.tsx +19 -6
- package/registry/components/stepper.tsx +63 -0
- package/registry/components/switch.tsx +7 -6
- package/registry/components/table.tsx +2 -3
- package/registry/components/tabs.tsx +3 -3
- package/registry/components/textarea.tsx +42 -6
- package/registry/components/toast.tsx +2 -2
- package/registry/components/toggle-group.tsx +72 -0
- package/registry/components/toggle.tsx +45 -20
- package/registry/components/tooltip.tsx +4 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export function Carousel({ className, children }: { className?: string; children: React.ReactNode }) {
|
|
6
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
7
|
+
const scroll = (dir: 1 | -1) => {
|
|
8
|
+
const el = ref.current;
|
|
9
|
+
if (el) el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: "smooth" });
|
|
10
|
+
};
|
|
11
|
+
return (
|
|
12
|
+
<div className={cn("relative", className)}>
|
|
13
|
+
<div
|
|
14
|
+
ref={ref}
|
|
15
|
+
className="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
aria-label="Previous"
|
|
22
|
+
onClick={() => scroll(-1)}
|
|
23
|
+
className="absolute -left-3 top-1/2 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted"
|
|
24
|
+
>
|
|
25
|
+
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden><path d="m15 18-6-6 6-6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
26
|
+
</button>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
aria-label="Next"
|
|
30
|
+
onClick={() => scroll(1)}
|
|
31
|
+
className="absolute -right-3 top-1/2 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted"
|
|
32
|
+
>
|
|
33
|
+
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden><path d="m9 18 6-6-6-6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function CarouselItem({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
40
|
+
return <div className={cn("min-w-0 shrink-0 grow-0 basis-full snap-start sm:basis-1/2 lg:basis-1/3", className)} {...props} />;
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface ChartDatum {
|
|
5
|
+
label: string;
|
|
6
|
+
value: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// A dependency-free SVG bar chart. For richer charts, swap in a charting lib.
|
|
10
|
+
export function BarChart({
|
|
11
|
+
data,
|
|
12
|
+
height = 180,
|
|
13
|
+
className,
|
|
14
|
+
}: {
|
|
15
|
+
data: ChartDatum[];
|
|
16
|
+
height?: number;
|
|
17
|
+
className?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const max = Math.max(1, ...data.map((d) => d.value));
|
|
20
|
+
const barW = 100 / (data.length * 1.6);
|
|
21
|
+
const gap = barW * 0.6;
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn("w-full", className)}>
|
|
24
|
+
<svg viewBox={`0 0 100 ${height / 2}`} preserveAspectRatio="none" className="h-44 w-full overflow-visible">
|
|
25
|
+
{data.map((d, i) => {
|
|
26
|
+
const h = (d.value / max) * (height / 2 - 6);
|
|
27
|
+
const x = i * (barW + gap) + gap;
|
|
28
|
+
return (
|
|
29
|
+
<rect
|
|
30
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
31
|
+
key={i}
|
|
32
|
+
x={x}
|
|
33
|
+
y={height / 2 - h}
|
|
34
|
+
width={barW}
|
|
35
|
+
height={h}
|
|
36
|
+
rx={1}
|
|
37
|
+
className="fill-primary"
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
})}
|
|
41
|
+
</svg>
|
|
42
|
+
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
|
|
43
|
+
{data.map((d, i) => (
|
|
44
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
45
|
+
<span key={i} className="flex-1 text-center">{d.label}</span>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
"use client";
|
|
2
1
|
import * as React from "react";
|
|
3
2
|
import { cn } from "@/lib/utils";
|
|
4
3
|
|
|
5
|
-
export
|
|
4
|
+
export type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement>;
|
|
5
|
+
|
|
6
|
+
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|
6
7
|
({ className, ...props }, ref) => (
|
|
7
8
|
<input
|
|
8
9
|
type="checkbox"
|
|
9
10
|
ref={ref}
|
|
10
11
|
className={cn(
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-accent)] focus-visible:ring-offset-1",
|
|
12
|
+
"size-4 shrink-0 rounded border border-input bg-background accent-primary",
|
|
13
|
+
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
14
14
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
15
15
|
className,
|
|
16
16
|
)}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface CodeBlockProps {
|
|
6
|
+
code: string;
|
|
7
|
+
language?: string;
|
|
8
|
+
filename?: string;
|
|
9
|
+
showLineNumbers?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escapeHtml(s: string): string {
|
|
14
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const KEYWORDS =
|
|
18
|
+
/\b(import|export|from|default|function|return|const|let|var|type|interface|extends|implements|new|await|async|if|else|for|while|switch|case|break|class|public|private|readonly|as)\b/g;
|
|
19
|
+
|
|
20
|
+
// Highlight a single (already-trusted, dev-authored) line. Order matters:
|
|
21
|
+
// numbers run before any markup is injected so digit-bearing class names
|
|
22
|
+
// (e.g. text-sky-400) are never re-matched.
|
|
23
|
+
function highlightSegment(code: string): string {
|
|
24
|
+
let e = escapeHtml(code);
|
|
25
|
+
e = e.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="text-amber-400">$1</span>');
|
|
26
|
+
e = e.replace(KEYWORDS, '<span class="text-fuchsia-400">$1</span>');
|
|
27
|
+
e = e.replace(/(<\/?)([A-Za-z][\w.]*)/g, '$1<span class="text-sky-400">$2</span>');
|
|
28
|
+
return e;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function highlightLine(line: string): string {
|
|
32
|
+
const re = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g;
|
|
33
|
+
let out = "";
|
|
34
|
+
let last = 0;
|
|
35
|
+
let m: RegExpExecArray | null;
|
|
36
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: scanner loop
|
|
37
|
+
while ((m = re.exec(line)) !== null) {
|
|
38
|
+
out += highlightSegment(line.slice(last, m.index));
|
|
39
|
+
if (m[1]) out += `<span class="text-zinc-500">${escapeHtml(m[1])}</span>`;
|
|
40
|
+
else out += `<span class="text-emerald-400">${escapeHtml(m[2] ?? "")}</span>`;
|
|
41
|
+
last = re.lastIndex;
|
|
42
|
+
}
|
|
43
|
+
out += highlightSegment(line.slice(last));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function CodeBlock({
|
|
48
|
+
code,
|
|
49
|
+
language,
|
|
50
|
+
filename,
|
|
51
|
+
showLineNumbers = true,
|
|
52
|
+
className,
|
|
53
|
+
}: CodeBlockProps) {
|
|
54
|
+
const [copied, setCopied] = React.useState(false);
|
|
55
|
+
const lines = code.replace(/\n$/, "").split("\n");
|
|
56
|
+
|
|
57
|
+
const copy = async () => {
|
|
58
|
+
try {
|
|
59
|
+
await navigator.clipboard.writeText(code);
|
|
60
|
+
setCopied(true);
|
|
61
|
+
setTimeout(() => setCopied(false), 1600);
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn("overflow-hidden rounded-xl border border-border bg-[#0b0b0e] text-zinc-100", className)}>
|
|
69
|
+
{(filename || language) && (
|
|
70
|
+
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2">
|
|
71
|
+
<span className="font-mono text-xs text-zinc-400">{filename ?? language}</span>
|
|
72
|
+
<CopyButton copied={copied} onCopy={copy} />
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
<div className="relative">
|
|
76
|
+
{!filename && !language && (
|
|
77
|
+
<div className="absolute right-2 top-2 z-10">
|
|
78
|
+
<CopyButton copied={copied} onCopy={copy} subtle />
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
<div className="overflow-x-auto">
|
|
82
|
+
<pre className="py-3 font-mono text-[0.8rem] leading-relaxed">
|
|
83
|
+
{lines.map((line, i) => (
|
|
84
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
85
|
+
<div key={i} className="flex px-4">
|
|
86
|
+
{showLineNumbers && (
|
|
87
|
+
<span className="mr-4 w-6 shrink-0 select-none text-right text-zinc-600">{i + 1}</span>
|
|
88
|
+
)}
|
|
89
|
+
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: dev-authored code */}
|
|
90
|
+
<code className="whitespace-pre" dangerouslySetInnerHTML={{ __html: highlightLine(line) || " " }} />
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</pre>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function CopyButton({ copied, onCopy, subtle }: { copied: boolean; onCopy: () => void; subtle?: boolean }) {
|
|
101
|
+
return (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={onCopy}
|
|
105
|
+
aria-label="Copy code"
|
|
106
|
+
className={cn(
|
|
107
|
+
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-zinc-400 transition-colors hover:bg-white/10 hover:text-zinc-100",
|
|
108
|
+
subtle && "bg-white/5",
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{copied ? (
|
|
112
|
+
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden><path d="M20 6 9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
113
|
+
) : (
|
|
114
|
+
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
|
|
115
|
+
)}
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use client";
|
|
2
1
|
import * as React from "react";
|
|
3
2
|
import { cn } from "@/lib/utils";
|
|
4
3
|
|
|
@@ -6,7 +5,7 @@ export function Code({ className, ...props }: React.HTMLAttributes<HTMLElement>)
|
|
|
6
5
|
return (
|
|
7
6
|
<code
|
|
8
7
|
className={cn(
|
|
9
|
-
"rounded border border-
|
|
8
|
+
"rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.85em] text-foreground",
|
|
10
9
|
className,
|
|
11
10
|
)}
|
|
12
11
|
{...props}
|
|
@@ -18,7 +17,7 @@ export function Pre({ className, ...props }: React.HTMLAttributes<HTMLPreElement
|
|
|
18
17
|
return (
|
|
19
18
|
<pre
|
|
20
19
|
className={cn(
|
|
21
|
-
"overflow-x-auto rounded-lg border border-
|
|
20
|
+
"overflow-x-auto rounded-lg border border-border bg-foreground p-4 font-mono text-[0.85em] text-background",
|
|
22
21
|
className,
|
|
23
22
|
)}
|
|
24
23
|
{...props}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
const CollapsibleContext = React.createContext<{ open: boolean; toggle: () => void } | null>(null);
|
|
6
|
+
|
|
7
|
+
export function Collapsible({
|
|
8
|
+
defaultOpen = false,
|
|
9
|
+
open: controlled,
|
|
10
|
+
onOpenChange,
|
|
11
|
+
className,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
defaultOpen?: boolean;
|
|
15
|
+
open?: boolean;
|
|
16
|
+
onOpenChange?: (v: boolean) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}) {
|
|
20
|
+
const [internal, setInternal] = React.useState(defaultOpen);
|
|
21
|
+
const open = controlled ?? internal;
|
|
22
|
+
const toggle = () => {
|
|
23
|
+
const next = !open;
|
|
24
|
+
if (controlled === undefined) setInternal(next);
|
|
25
|
+
onOpenChange?.(next);
|
|
26
|
+
};
|
|
27
|
+
return (
|
|
28
|
+
<CollapsibleContext.Provider value={{ open, toggle }}>
|
|
29
|
+
<div className={className}>{children}</div>
|
|
30
|
+
</CollapsibleContext.Provider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CollapsibleTrigger({
|
|
35
|
+
className,
|
|
36
|
+
children,
|
|
37
|
+
asChild,
|
|
38
|
+
}: {
|
|
39
|
+
className?: string;
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
asChild?: boolean;
|
|
42
|
+
}) {
|
|
43
|
+
const ctx = React.useContext(CollapsibleContext);
|
|
44
|
+
if (asChild && React.isValidElement(children)) {
|
|
45
|
+
return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
|
|
46
|
+
onClick: () => ctx?.toggle(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<button type="button" onClick={() => ctx?.toggle()} aria-expanded={ctx?.open} className={className}>
|
|
51
|
+
{children}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function CollapsibleContent({ className, children }: { className?: string; children: React.ReactNode }) {
|
|
57
|
+
const ctx = React.useContext(CollapsibleContext);
|
|
58
|
+
if (!ctx?.open) return null;
|
|
59
|
+
return <div className={cn("overflow-hidden", className)}>{children}</div>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface ComboboxOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Combobox({
|
|
11
|
+
options,
|
|
12
|
+
value: controlled,
|
|
13
|
+
defaultValue = "",
|
|
14
|
+
onValueChange,
|
|
15
|
+
placeholder = "Select…",
|
|
16
|
+
searchPlaceholder = "Search…",
|
|
17
|
+
emptyText = "No results.",
|
|
18
|
+
className,
|
|
19
|
+
}: {
|
|
20
|
+
options: ComboboxOption[];
|
|
21
|
+
value?: string;
|
|
22
|
+
defaultValue?: string;
|
|
23
|
+
onValueChange?: (v: string) => void;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
searchPlaceholder?: string;
|
|
26
|
+
emptyText?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}) {
|
|
29
|
+
const [internal, setInternal] = React.useState(defaultValue);
|
|
30
|
+
const value = controlled ?? internal;
|
|
31
|
+
const [open, setOpen] = React.useState(false);
|
|
32
|
+
const [query, setQuery] = React.useState("");
|
|
33
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
34
|
+
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
const onClick = (e: MouseEvent) => {
|
|
38
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener("mousedown", onClick);
|
|
41
|
+
return () => document.removeEventListener("mousedown", onClick);
|
|
42
|
+
}, [open]);
|
|
43
|
+
|
|
44
|
+
const selected = options.find((o) => o.value === value);
|
|
45
|
+
const filtered = options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()));
|
|
46
|
+
|
|
47
|
+
const choose = (v: string) => {
|
|
48
|
+
if (controlled === undefined) setInternal(v);
|
|
49
|
+
onValueChange?.(v);
|
|
50
|
+
setOpen(false);
|
|
51
|
+
setQuery("");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div ref={ref} className={cn("relative inline-block w-full max-w-xs", className)}>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => setOpen((o) => !o)}
|
|
59
|
+
className="flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 text-sm shadow-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
60
|
+
>
|
|
61
|
+
<span className={cn("truncate", !selected && "text-muted-foreground")}>
|
|
62
|
+
{selected ? selected.label : placeholder}
|
|
63
|
+
</span>
|
|
64
|
+
<svg viewBox="0 0 24 24" className="size-4 shrink-0 text-muted-foreground" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
65
|
+
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
66
|
+
</svg>
|
|
67
|
+
</button>
|
|
68
|
+
{open && (
|
|
69
|
+
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md">
|
|
70
|
+
<input
|
|
71
|
+
autoFocus
|
|
72
|
+
value={query}
|
|
73
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
74
|
+
placeholder={searchPlaceholder}
|
|
75
|
+
className="w-full border-b border-border bg-transparent px-3 py-2 text-sm outline-hidden placeholder:text-muted-foreground"
|
|
76
|
+
/>
|
|
77
|
+
<div className="max-h-56 overflow-y-auto p-1">
|
|
78
|
+
{filtered.length === 0 ? (
|
|
79
|
+
<p className="px-2 py-6 text-center text-sm text-muted-foreground">{emptyText}</p>
|
|
80
|
+
) : (
|
|
81
|
+
filtered.map((o) => (
|
|
82
|
+
<button
|
|
83
|
+
key={o.value}
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => choose(o.value)}
|
|
86
|
+
className="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
|
87
|
+
>
|
|
88
|
+
{o.label}
|
|
89
|
+
{o.value === value && (
|
|
90
|
+
<svg viewBox="0 0 24 24" className="size-4 text-primary" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
91
|
+
<path d="M20 6 9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
|
92
|
+
</svg>
|
|
93
|
+
)}
|
|
94
|
+
</button>
|
|
95
|
+
))
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -52,18 +52,18 @@ export function Command({
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
|
-
<div className={cn("rounded-lg border border-
|
|
55
|
+
<div className={cn("rounded-lg border border-border bg-popover shadow-md", className)}>
|
|
56
56
|
<input
|
|
57
57
|
autoFocus
|
|
58
58
|
value={query}
|
|
59
59
|
onChange={(e) => setQuery(e.target.value)}
|
|
60
60
|
onKeyDown={onKey}
|
|
61
61
|
placeholder={placeholder}
|
|
62
|
-
className="w-full border-b border-
|
|
62
|
+
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-hidden placeholder:text-muted-foreground"
|
|
63
63
|
/>
|
|
64
64
|
<ul className="max-h-72 overflow-y-auto p-1">
|
|
65
65
|
{filtered.length === 0 ? (
|
|
66
|
-
<li className="px-3 py-6 text-center text-sm text-
|
|
66
|
+
<li className="px-3 py-6 text-center text-sm text-muted-foreground">No results</li>
|
|
67
67
|
) : (
|
|
68
68
|
filtered.map((item, i) => (
|
|
69
69
|
<li
|
|
@@ -75,12 +75,12 @@ export function Command({
|
|
|
75
75
|
}}
|
|
76
76
|
className={cn(
|
|
77
77
|
"flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm",
|
|
78
|
-
i === activeIndex && "bg-
|
|
78
|
+
i === activeIndex && "bg-muted text-foreground",
|
|
79
79
|
)}
|
|
80
80
|
>
|
|
81
81
|
<span>{item.label}</span>
|
|
82
82
|
{item.shortcut ? (
|
|
83
|
-
<kbd className="rounded border border-
|
|
83
|
+
<kbd className="rounded border border-border px-1 font-mono text-[0.6875rem]">
|
|
84
84
|
{item.shortcut}
|
|
85
85
|
</kbd>
|
|
86
86
|
) : null}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
const ContextMenuContext = React.createContext<{
|
|
6
|
+
pos: { x: number; y: number } | null;
|
|
7
|
+
open: (x: number, y: number) => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
} | null>(null);
|
|
10
|
+
|
|
11
|
+
export function ContextMenu({ children }: { children: React.ReactNode }) {
|
|
12
|
+
const [pos, setPos] = React.useState<{ x: number; y: number } | null>(null);
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
if (!pos) return;
|
|
15
|
+
const close = () => setPos(null);
|
|
16
|
+
document.addEventListener("click", close);
|
|
17
|
+
document.addEventListener("scroll", close, true);
|
|
18
|
+
return () => {
|
|
19
|
+
document.removeEventListener("click", close);
|
|
20
|
+
document.removeEventListener("scroll", close, true);
|
|
21
|
+
};
|
|
22
|
+
}, [pos]);
|
|
23
|
+
return (
|
|
24
|
+
<ContextMenuContext.Provider value={{ pos, open: (x, y) => setPos({ x, y }), close: () => setPos(null) }}>
|
|
25
|
+
{children}
|
|
26
|
+
</ContextMenuContext.Provider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ContextMenuTrigger({ className, children }: { className?: string; children: React.ReactNode }) {
|
|
31
|
+
const ctx = React.useContext(ContextMenuContext);
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={className}
|
|
35
|
+
onContextMenu={(e) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
ctx?.open(e.clientX, e.clientY);
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ContextMenuContent({ className, children }: { className?: string; children: React.ReactNode }) {
|
|
46
|
+
const ctx = React.useContext(ContextMenuContext);
|
|
47
|
+
if (!ctx?.pos) return null;
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
role="menu"
|
|
51
|
+
className={cn(
|
|
52
|
+
"fixed z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
style={{ left: ctx.pos.x, top: ctx.pos.y }}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ContextMenuItem({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
role="menuitem"
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden hover:bg-muted focus:bg-muted",
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ContextMenuSeparator() {
|
|
76
|
+
return <div className="my-1 h-px bg-border" />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ContextMenuLabel({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
80
|
+
return <div className={cn("px-2 py-1.5 text-xs font-semibold text-muted-foreground", className)} {...props} />;
|
|
81
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface DataTableColumn<T> {
|
|
6
|
+
key: keyof T & string;
|
|
7
|
+
header: string;
|
|
8
|
+
sortable?: boolean;
|
|
9
|
+
render?: (row: T) => React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function DataTable<T extends Record<string, React.ReactNode>>({
|
|
14
|
+
columns,
|
|
15
|
+
data,
|
|
16
|
+
className,
|
|
17
|
+
}: {
|
|
18
|
+
columns: DataTableColumn<T>[];
|
|
19
|
+
data: T[];
|
|
20
|
+
className?: string;
|
|
21
|
+
}) {
|
|
22
|
+
const [sort, setSort] = React.useState<{ key: string; dir: 1 | -1 } | null>(null);
|
|
23
|
+
|
|
24
|
+
const rows = React.useMemo(() => {
|
|
25
|
+
if (!sort) return data;
|
|
26
|
+
const sorted = [...data].sort((a, b) => {
|
|
27
|
+
const av = a[sort.key];
|
|
28
|
+
const bv = b[sort.key];
|
|
29
|
+
return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sort.dir;
|
|
30
|
+
});
|
|
31
|
+
return sorted;
|
|
32
|
+
}, [data, sort]);
|
|
33
|
+
|
|
34
|
+
const toggleSort = (key: string) =>
|
|
35
|
+
setSort((s) => (s?.key === key ? { key, dir: s.dir === 1 ? -1 : 1 } : { key, dir: 1 }));
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn("w-full overflow-auto rounded-lg border border-border", className)}>
|
|
39
|
+
<table className="w-full caption-bottom text-sm">
|
|
40
|
+
<thead className="[&_tr]:border-b">
|
|
41
|
+
<tr>
|
|
42
|
+
{columns.map((c) => (
|
|
43
|
+
<th key={c.key} className={cn("h-10 px-3 text-left align-middle text-xs font-semibold uppercase tracking-wider text-muted-foreground", c.className)}>
|
|
44
|
+
{c.sortable ? (
|
|
45
|
+
<button type="button" onClick={() => toggleSort(c.key)} className="inline-flex items-center gap-1 hover:text-foreground">
|
|
46
|
+
{c.header}
|
|
47
|
+
<span className="text-[0.7rem]">{sort?.key === c.key ? (sort.dir === 1 ? "▲" : "▼") : "↕"}</span>
|
|
48
|
+
</button>
|
|
49
|
+
) : (
|
|
50
|
+
c.header
|
|
51
|
+
)}
|
|
52
|
+
</th>
|
|
53
|
+
))}
|
|
54
|
+
</tr>
|
|
55
|
+
</thead>
|
|
56
|
+
<tbody className="[&_tr:last-child]:border-0">
|
|
57
|
+
{rows.map((row, i) => (
|
|
58
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
59
|
+
<tr key={i} className="border-b border-border transition-colors hover:bg-muted">
|
|
60
|
+
{columns.map((c) => (
|
|
61
|
+
<td key={c.key} className={cn("p-3 align-middle", c.className)}>
|
|
62
|
+
{c.render ? c.render(row) : row[c.key]}
|
|
63
|
+
</td>
|
|
64
|
+
))}
|
|
65
|
+
</tr>
|
|
66
|
+
))}
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Calendar } from "@/components/ui/calendar";
|
|
5
|
+
|
|
6
|
+
export function DatePicker({
|
|
7
|
+
value: controlled,
|
|
8
|
+
defaultValue,
|
|
9
|
+
onChange,
|
|
10
|
+
placeholder = "Pick a date",
|
|
11
|
+
className,
|
|
12
|
+
}: {
|
|
13
|
+
value?: Date | null;
|
|
14
|
+
defaultValue?: Date;
|
|
15
|
+
onChange?: (d: Date) => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const [internal, setInternal] = React.useState<Date | null>(defaultValue ?? null);
|
|
20
|
+
const value = controlled !== undefined ? controlled : internal;
|
|
21
|
+
const [open, setOpen] = React.useState(false);
|
|
22
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (!open) return;
|
|
26
|
+
const onClick = (e: MouseEvent) => {
|
|
27
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
28
|
+
};
|
|
29
|
+
document.addEventListener("mousedown", onClick);
|
|
30
|
+
return () => document.removeEventListener("mousedown", onClick);
|
|
31
|
+
}, [open]);
|
|
32
|
+
|
|
33
|
+
const pick = (d: Date) => {
|
|
34
|
+
if (controlled === undefined) setInternal(d);
|
|
35
|
+
onChange?.(d);
|
|
36
|
+
setOpen(false);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div ref={ref} className={cn("relative inline-block", className)}>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={() => setOpen((o) => !o)}
|
|
44
|
+
className="inline-flex h-9 w-56 items-center gap-2 rounded-lg border border-input bg-background px-3 text-left text-sm shadow-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
45
|
+
>
|
|
46
|
+
<svg viewBox="0 0 24 24" className="size-4 text-muted-foreground" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
|
47
|
+
<span className={cn(!value && "text-muted-foreground")}>
|
|
48
|
+
{value ? value.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }) : placeholder}
|
|
49
|
+
</span>
|
|
50
|
+
</button>
|
|
51
|
+
{open && (
|
|
52
|
+
<div className="absolute z-50 mt-2">
|
|
53
|
+
<Calendar value={value} onChange={pick} />
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|