@xenide-io/the-old-ui-theme 0.3.0 → 0.3.2
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/chunk-5HSOBJ4F.mjs +5968 -0
- package/dist/{index-D1RTT2Ap.d.mts → index-BgG8Homu.d.mts} +419 -2
- package/dist/{index-D1RTT2Ap.d.ts → index-BgG8Homu.d.ts} +419 -2
- package/dist/index.d.mts +198 -95
- package/dist/index.d.ts +198 -95
- package/dist/index.js +2995 -1136
- package/dist/index.mjs +629 -735
- package/dist/ui.d.mts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +1986 -156
- package/dist/ui.mjs +76 -3
- package/package.json +4 -14
- package/src/app/globals.css +976 -2
- package/src/components/charts/index.ts +9 -8
- package/src/components/charts/quill/BarChart.tsx +85 -0
- package/src/components/charts/quill/FunnelChart.tsx +49 -0
- package/src/components/charts/quill/InsightShell.tsx +27 -0
- package/src/components/charts/quill/MetricCard.tsx +44 -0
- package/src/components/charts/quill/PieChart.tsx +81 -0
- package/src/components/charts/quill/Sparkline.tsx +57 -0
- package/src/components/charts/quill/TimeSeriesLineChart.tsx +62 -0
- package/src/components/charts/quill/index.ts +9 -0
- package/src/components/icons/icons.tsx +2 -0
- package/src/components/icons/index.ts +3 -0
- package/src/components/icons/lemon-brand-icons.tsx +16 -0
- package/src/components/icons/lemon-category-icons.tsx +72 -0
- package/src/components/icons/lemon-icons.tsx +57 -0
- package/src/components/sections/IconShowcase.tsx +110 -7
- package/src/components/sections/NewComponentsShowcase.tsx +1052 -0
- package/src/components/sections/ProductLayoutsShowcase.tsx +57 -117
- package/src/components/sections/QuillChartShowcase.tsx +92 -0
- package/src/components/sections/QuillDashboardShowcase.tsx +149 -0
- package/src/components/sections/SidebarShowcase.tsx +152 -0
- package/src/components/sections/TableShowcase.tsx +2 -0
- package/src/components/sections/TooltipShowcase.tsx +19 -25
- package/src/components/ui/AlertDialog.tsx +62 -0
- package/src/components/ui/AppLayout.tsx +27 -0
- package/src/components/ui/Autocomplete.tsx +106 -0
- package/src/components/ui/Banner.tsx +63 -0
- package/src/components/ui/ButtonGroup.tsx +16 -0
- package/src/components/ui/Chip.tsx +46 -0
- package/src/components/ui/Collapsible.tsx +46 -0
- package/src/components/ui/Combobox.tsx +119 -0
- package/src/components/ui/ContextMenu.tsx +81 -0
- package/src/components/ui/DataTable.tsx +133 -0
- package/src/components/ui/DatePicker.tsx +96 -0
- package/src/components/ui/Dialog.tsx +75 -0
- package/src/components/ui/Divider.tsx +39 -0
- package/src/components/ui/Dot.tsx +32 -0
- package/src/components/ui/InputGroup.tsx +16 -0
- package/src/components/ui/Lettermark.tsx +47 -0
- package/src/components/ui/Link.tsx +32 -0
- package/src/components/ui/LoadingBar.tsx +25 -0
- package/src/components/ui/Menubar.tsx +66 -0
- package/src/components/ui/Metric.tsx +34 -0
- package/src/components/ui/NumberField.tsx +76 -0
- package/src/components/ui/Popover.tsx +73 -0
- package/src/components/ui/ProgressCircle.tsx +63 -0
- package/src/components/ui/Resizable.tsx +71 -0
- package/src/components/ui/Row.tsx +51 -0
- package/src/components/ui/ScrollArea.tsx +54 -0
- package/src/components/ui/Sidebar.tsx +77 -0
- package/src/components/ui/Snack.tsx +48 -0
- package/src/components/ui/Spinner.tsx +30 -0
- package/src/components/ui/Splotch.tsx +33 -0
- package/src/components/ui/Table.tsx +2 -0
- package/src/components/ui/Tag.tsx +65 -0
- package/src/components/ui/Toggle.tsx +62 -0
- package/src/components/ui/Tooltip.test.tsx +58 -0
- package/src/components/ui/Tooltip.tsx +211 -0
- package/src/components/ui/Widget.tsx +31 -0
- package/src/components/ui/index.ts +275 -21
- package/dist/chunk-FDMD3IIN.mjs +0 -3831
- package/src/components/charts/ph-insight-charts.tsx +0 -162
- package/src/components/dashboard/DashboardFiltersBar.tsx +0 -19
- package/src/components/dashboard/DashboardGrid.tsx +0 -23
- package/src/components/dashboard/DashboardInsightPlaceholder.tsx +0 -37
- package/src/components/dashboard/DashboardKpiCard.tsx +0 -25
- package/src/components/dashboard/DashboardToolbar.tsx +0 -23
- package/src/components/dashboard/DashboardWell.tsx +0 -10
- package/src/components/dashboard/InsightMiniCard.tsx +0 -26
- package/src/components/dashboard/InsightShell.tsx +0 -37
- package/src/components/dashboard/InsightShellHeader.tsx +0 -22
- package/src/components/dashboard/MiniTrendBars.tsx +0 -51
- package/src/components/dashboard/index.ts +0 -31
- package/src/components/dashboard/types.ts +0 -9
- package/src/components/sections/ChartShowcase.tsx +0 -190
- package/src/components/sections/DashboardShowcase.tsx +0 -191
- package/src/components/sections/StatShowcase.tsx +0 -129
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { Modal, type ModalProps } from "@/components/ui/Modal";
|
|
5
|
+
import { Button, type ButtonProps } from "@/components/ui/Button";
|
|
6
|
+
|
|
7
|
+
export interface AlertDialogProps extends Omit<ModalProps, "onClose"> {
|
|
8
|
+
isOpen?: boolean;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
onConfirm?: () => void | Promise<void>;
|
|
11
|
+
confirmText?: string;
|
|
12
|
+
cancelText?: string;
|
|
13
|
+
confirmProps?: Partial<ButtonProps>;
|
|
14
|
+
cancelProps?: Partial<ButtonProps>;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AlertDialog({
|
|
19
|
+
isOpen: openProp,
|
|
20
|
+
onClose,
|
|
21
|
+
onConfirm,
|
|
22
|
+
confirmText = "Confirm",
|
|
23
|
+
cancelText = "Cancel",
|
|
24
|
+
confirmProps,
|
|
25
|
+
cancelProps,
|
|
26
|
+
children,
|
|
27
|
+
title,
|
|
28
|
+
...modalProps
|
|
29
|
+
}: AlertDialogProps) {
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const isOpen = openProp ?? true;
|
|
32
|
+
|
|
33
|
+
const handleConfirm = async () => {
|
|
34
|
+
if (!onConfirm) return;
|
|
35
|
+
setLoading(true);
|
|
36
|
+
try { await onConfirm(); } finally { setLoading(false); }
|
|
37
|
+
onClose?.();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Modal
|
|
42
|
+
open={isOpen}
|
|
43
|
+
onClose={onClose}
|
|
44
|
+
title={title}
|
|
45
|
+
footer={
|
|
46
|
+
<div className="flex justify-end gap-2">
|
|
47
|
+
<Button variant="secondary" {...cancelProps} onClick={onClose}>{cancelText}</Button>
|
|
48
|
+
{onConfirm && (
|
|
49
|
+
<Button variant="primary" loading={loading} {...confirmProps} onClick={handleConfirm}>
|
|
50
|
+
{confirmText}
|
|
51
|
+
</Button>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
}
|
|
55
|
+
{...modalProps}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</Modal>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
AlertDialog.displayName = "AlertDialog";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface AppLayoutProps {
|
|
7
|
+
sidebar: ReactNode;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
sidebarWidth?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AppLayout({ sidebar, children, sidebarWidth = 240, className }: AppLayoutProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className={cn("ph-app-layout grid min-h-screen", className)}
|
|
17
|
+
style={{ gridTemplateColumns: `${sidebarWidth}px minmax(0, 1fr)` }}
|
|
18
|
+
>
|
|
19
|
+
<aside className="ph-app-layout__sidebar border-r border-ph-border bg-ph-surface overflow-y-auto">
|
|
20
|
+
{sidebar}
|
|
21
|
+
</aside>
|
|
22
|
+
<main className="ph-app-layout__content min-w-0 overflow-y-auto bg-ph-canvas">
|
|
23
|
+
{children}
|
|
24
|
+
</main>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, type InputHTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface AutocompleteOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AutocompleteProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onSelect"> {
|
|
12
|
+
options: AutocompleteOption[];
|
|
13
|
+
onSelect?: (option: AutocompleteOption) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Autocomplete({
|
|
17
|
+
options,
|
|
18
|
+
onSelect,
|
|
19
|
+
className,
|
|
20
|
+
value: controlledValue,
|
|
21
|
+
onChange,
|
|
22
|
+
placeholder = "Type to search...",
|
|
23
|
+
...inputProps
|
|
24
|
+
}: AutocompleteProps) {
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const [search, setSearch] = useState("");
|
|
27
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
28
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
const filtered = options.filter((o) =>
|
|
33
|
+
o.label.toLowerCase().includes(search.toLowerCase())
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const handleSelect = (opt: AutocompleteOption) => {
|
|
37
|
+
setSearch(opt.label);
|
|
38
|
+
setOpen(false);
|
|
39
|
+
onSelect?.(opt);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) { setActiveIndex(-1); return; }
|
|
44
|
+
const handle = (e: MouseEvent) => {
|
|
45
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
46
|
+
setOpen(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === "ArrowDown") { setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); e.preventDefault(); }
|
|
51
|
+
if (e.key === "ArrowUp") { setActiveIndex((i) => Math.max(i - 1, 0)); e.preventDefault(); }
|
|
52
|
+
if (e.key === "Enter" && activeIndex >= 0) { handleSelect(filtered[activeIndex]); e.preventDefault(); }
|
|
53
|
+
if (e.key === "Escape") { setOpen(false); }
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("mousedown", handle);
|
|
56
|
+
document.addEventListener("keydown", handleKey);
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener("mousedown", handle);
|
|
59
|
+
document.removeEventListener("keydown", handleKey);
|
|
60
|
+
};
|
|
61
|
+
}, [open, filtered, activeIndex]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (activeIndex >= 0 && listRef.current) {
|
|
65
|
+
const el = listRef.current.children[activeIndex] as HTMLElement;
|
|
66
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
67
|
+
}
|
|
68
|
+
}, [activeIndex]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div ref={containerRef} className={cn("ph-autocomplete relative", className)}>
|
|
72
|
+
<input
|
|
73
|
+
ref={inputRef}
|
|
74
|
+
type="text"
|
|
75
|
+
value={search}
|
|
76
|
+
onChange={(e) => { setSearch(e.target.value); setOpen(true); onChange?.(e); }}
|
|
77
|
+
onFocus={() => setOpen(true)}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
className="ph-input w-full"
|
|
80
|
+
{...inputProps}
|
|
81
|
+
/>
|
|
82
|
+
{open && filtered.length > 0 && (
|
|
83
|
+
<div ref={listRef} className="ph-autocomplete__dropdown" role="listbox">
|
|
84
|
+
{filtered.map((opt, i) => (
|
|
85
|
+
<button
|
|
86
|
+
key={opt.value}
|
|
87
|
+
type="button"
|
|
88
|
+
role="option"
|
|
89
|
+
aria-selected={i === activeIndex}
|
|
90
|
+
onMouseDown={() => handleSelect(opt)}
|
|
91
|
+
onMouseEnter={() => setActiveIndex(i)}
|
|
92
|
+
className={cn(
|
|
93
|
+
"ph-autocomplete__option",
|
|
94
|
+
i === activeIndex && "ph-autocomplete__option--active"
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{opt.label}
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Autocomplete.displayName = "Autocomplete";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export type BannerType = "info" | "success" | "warning" | "danger" | "ai";
|
|
5
|
+
|
|
6
|
+
export interface BannerProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
type?: BannerType;
|
|
8
|
+
onClose?: () => void;
|
|
9
|
+
action?: ReactNode;
|
|
10
|
+
hideIcon?: boolean;
|
|
11
|
+
square?: boolean;
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const typeClass: Record<BannerType, string> = {
|
|
17
|
+
info: "ph-banner-info",
|
|
18
|
+
success: "ph-banner-success",
|
|
19
|
+
warning: "ph-banner-warning",
|
|
20
|
+
danger: "ph-banner-danger",
|
|
21
|
+
ai: "ph-banner-ai",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const Banner = forwardRef<HTMLDivElement, BannerProps>(function Banner({
|
|
25
|
+
type = "info",
|
|
26
|
+
onClose,
|
|
27
|
+
action,
|
|
28
|
+
hideIcon = false,
|
|
29
|
+
square = false,
|
|
30
|
+
icon,
|
|
31
|
+
className,
|
|
32
|
+
children,
|
|
33
|
+
...props
|
|
34
|
+
}, ref) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
ref={ref}
|
|
38
|
+
role="alert"
|
|
39
|
+
className={cn(
|
|
40
|
+
"ph-banner",
|
|
41
|
+
typeClass[type],
|
|
42
|
+
square && "ph-banner--square",
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
<div className="ph-banner__body">
|
|
48
|
+
{icon}
|
|
49
|
+
<div className="ph-banner__content">{children}</div>
|
|
50
|
+
{action && <div className="ph-banner__action">{action}</div>}
|
|
51
|
+
{onClose && (
|
|
52
|
+
<button type="button" onClick={onClose} className="ph-banner__close" aria-label="Dismiss">
|
|
53
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
54
|
+
<path d="M4 4l8 8M12 4l-8 8" />
|
|
55
|
+
</svg>
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
Banner.displayName = "Banner";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ButtonGroup({ children, className, ...props }: ButtonGroupProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={cn("ph-btn-group", className)} {...props}>
|
|
11
|
+
{children}
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ButtonGroup.displayName = "ButtonGroup";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface ChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
selected?: boolean;
|
|
6
|
+
onRemove?: () => void;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip({
|
|
11
|
+
selected = false,
|
|
12
|
+
onRemove,
|
|
13
|
+
children,
|
|
14
|
+
className,
|
|
15
|
+
...props
|
|
16
|
+
}, ref) {
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
ref={ref}
|
|
20
|
+
type="button"
|
|
21
|
+
className={cn(
|
|
22
|
+
"ph-chip",
|
|
23
|
+
selected && "ph-chip--selected",
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
>
|
|
28
|
+
<span className="ph-chip__text">{children}</span>
|
|
29
|
+
{onRemove && (
|
|
30
|
+
<span
|
|
31
|
+
role="button"
|
|
32
|
+
tabIndex={-1}
|
|
33
|
+
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
34
|
+
className="ph-chip__remove"
|
|
35
|
+
aria-label="Remove"
|
|
36
|
+
>
|
|
37
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
38
|
+
<path d="M2 2l6 6M8 2l-6 6" />
|
|
39
|
+
</svg>
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
Chip.displayName = "Chip";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface CollapsibleProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
trigger: ReactNode;
|
|
8
|
+
open?: boolean;
|
|
9
|
+
onOpenChange?: (open: boolean) => void;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Collapsible({
|
|
14
|
+
trigger,
|
|
15
|
+
open: controlledOpen,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: CollapsibleProps) {
|
|
21
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
22
|
+
const isOpen = controlledOpen ?? internalOpen;
|
|
23
|
+
const setOpen = onOpenChange ?? setInternalOpen;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={cn("ph-collapsible", className)} {...props}>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={() => setOpen(!isOpen)}
|
|
30
|
+
className="ph-collapsible__trigger"
|
|
31
|
+
aria-expanded={isOpen}
|
|
32
|
+
>
|
|
33
|
+
<svg
|
|
34
|
+
className={cn("ph-collapsible__chevron", isOpen && "ph-collapsible__chevron--open")}
|
|
35
|
+
width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
|
36
|
+
>
|
|
37
|
+
<path d="M4 5l3 3 3-3" />
|
|
38
|
+
</svg>
|
|
39
|
+
{trigger}
|
|
40
|
+
</button>
|
|
41
|
+
{isOpen && <div className="ph-collapsible__content">{children}</div>}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Collapsible.displayName = "Collapsible";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface ComboboxOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ComboboxProps {
|
|
12
|
+
options: ComboboxOption[];
|
|
13
|
+
value?: string[];
|
|
14
|
+
onChange?: (value: string[]) => void;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Combobox({
|
|
21
|
+
options,
|
|
22
|
+
value = [],
|
|
23
|
+
onChange,
|
|
24
|
+
placeholder = "Search...",
|
|
25
|
+
className,
|
|
26
|
+
disabled,
|
|
27
|
+
}: ComboboxProps) {
|
|
28
|
+
const [open, setOpen] = useState(false);
|
|
29
|
+
const [search, setSearch] = useState("");
|
|
30
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
31
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
|
|
33
|
+
const filtered = options.filter((o) =>
|
|
34
|
+
o.label.toLowerCase().includes(search.toLowerCase())
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const selectedSet = new Set(value);
|
|
38
|
+
|
|
39
|
+
const toggle = (val: string) => {
|
|
40
|
+
const next = selectedSet.has(val)
|
|
41
|
+
? value.filter((v) => v !== val)
|
|
42
|
+
: [...value, val];
|
|
43
|
+
onChange?.(next);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
const handle = (e: MouseEvent) => {
|
|
49
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
50
|
+
setOpen(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener("mousedown", handle);
|
|
54
|
+
return () => document.removeEventListener("mousedown", handle);
|
|
55
|
+
}, [open]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div ref={containerRef} className={cn("ph-combobox relative", className)}>
|
|
59
|
+
<div className="ph-combobox__input-wrap">
|
|
60
|
+
{value.length > 0 && (
|
|
61
|
+
<div className="ph-combobox__chips">
|
|
62
|
+
{value.map((v) => {
|
|
63
|
+
const opt = options.find((o) => o.value === v);
|
|
64
|
+
return (
|
|
65
|
+
<span key={v} className="ph-combobox__chip">
|
|
66
|
+
{opt?.label ?? v}
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => toggle(v)}
|
|
70
|
+
className="ph-combobox__chip-remove"
|
|
71
|
+
aria-label={`Remove ${opt?.label ?? v}`}
|
|
72
|
+
>
|
|
73
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
74
|
+
<path d="M2 2l6 6M8 2l-6 6" />
|
|
75
|
+
</svg>
|
|
76
|
+
</button>
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<input
|
|
83
|
+
ref={inputRef}
|
|
84
|
+
type="text"
|
|
85
|
+
value={search}
|
|
86
|
+
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
|
87
|
+
onFocus={() => setOpen(true)}
|
|
88
|
+
placeholder={value.length === 0 ? placeholder : ""}
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
className="ph-combobox__input"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
{open && filtered.length > 0 && (
|
|
94
|
+
<div className="ph-combobox__dropdown">
|
|
95
|
+
{filtered.map((opt) => (
|
|
96
|
+
<button
|
|
97
|
+
key={opt.value}
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => toggle(opt.value)}
|
|
100
|
+
className={cn(
|
|
101
|
+
"ph-combobox__option",
|
|
102
|
+
selectedSet.has(opt.value) && "ph-combobox__option--selected"
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{selectedSet.has(opt.value) && (
|
|
106
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
107
|
+
<path d="M2.5 6l2.5 2.5 4.5-5" />
|
|
108
|
+
</svg>
|
|
109
|
+
)}
|
|
110
|
+
<span>{opt.label}</span>
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
Combobox.displayName = "Combobox";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, type ReactNode, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface ContextMenuItem {
|
|
7
|
+
label: string;
|
|
8
|
+
onClick?: () => void;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
separator?: boolean;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ContextMenuProps extends HTMLAttributes<HTMLDivElement> {
|
|
15
|
+
items: ContextMenuItem[];
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ContextMenu({ items, children, className, ...props }: ContextMenuProps) {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
22
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open) return;
|
|
26
|
+
const handle = (e: MouseEvent) => {
|
|
27
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
28
|
+
setOpen(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
32
|
+
if (e.key === "Escape") setOpen(false);
|
|
33
|
+
};
|
|
34
|
+
document.addEventListener("mousedown", handle);
|
|
35
|
+
document.addEventListener("keydown", handleKey);
|
|
36
|
+
return () => {
|
|
37
|
+
document.removeEventListener("mousedown", handle);
|
|
38
|
+
document.removeEventListener("keydown", handleKey);
|
|
39
|
+
};
|
|
40
|
+
}, [open]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn("ph-context-menu", className)}
|
|
45
|
+
onContextMenu={(e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setPos({ x: e.clientX, y: e.clientY });
|
|
48
|
+
setOpen(true);
|
|
49
|
+
}}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
{open && (
|
|
54
|
+
<div
|
|
55
|
+
ref={menuRef}
|
|
56
|
+
className="ph-context-menu__panel"
|
|
57
|
+
style={{ left: pos.x, top: pos.y }}
|
|
58
|
+
>
|
|
59
|
+
{items.map((item, i) =>
|
|
60
|
+
item.separator ? (
|
|
61
|
+
<div key={i} className="ph-context-menu__separator" />
|
|
62
|
+
) : (
|
|
63
|
+
<button
|
|
64
|
+
key={i}
|
|
65
|
+
type="button"
|
|
66
|
+
disabled={item.disabled}
|
|
67
|
+
onClick={() => { item.onClick?.(); setOpen(false); }}
|
|
68
|
+
className="ph-context-menu__item"
|
|
69
|
+
>
|
|
70
|
+
{item.icon && <span className="ph-context-menu__icon">{item.icon}</span>}
|
|
71
|
+
<span>{item.label}</span>
|
|
72
|
+
</button>
|
|
73
|
+
)
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ContextMenu.displayName = "ContextMenu";
|