@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.
Files changed (89) hide show
  1. package/dist/chunk-5HSOBJ4F.mjs +5968 -0
  2. package/dist/{index-D1RTT2Ap.d.mts → index-BgG8Homu.d.mts} +419 -2
  3. package/dist/{index-D1RTT2Ap.d.ts → index-BgG8Homu.d.ts} +419 -2
  4. package/dist/index.d.mts +198 -95
  5. package/dist/index.d.ts +198 -95
  6. package/dist/index.js +2995 -1136
  7. package/dist/index.mjs +629 -735
  8. package/dist/ui.d.mts +1 -1
  9. package/dist/ui.d.ts +1 -1
  10. package/dist/ui.js +1986 -156
  11. package/dist/ui.mjs +76 -3
  12. package/package.json +4 -14
  13. package/src/app/globals.css +976 -2
  14. package/src/components/charts/index.ts +9 -8
  15. package/src/components/charts/quill/BarChart.tsx +85 -0
  16. package/src/components/charts/quill/FunnelChart.tsx +49 -0
  17. package/src/components/charts/quill/InsightShell.tsx +27 -0
  18. package/src/components/charts/quill/MetricCard.tsx +44 -0
  19. package/src/components/charts/quill/PieChart.tsx +81 -0
  20. package/src/components/charts/quill/Sparkline.tsx +57 -0
  21. package/src/components/charts/quill/TimeSeriesLineChart.tsx +62 -0
  22. package/src/components/charts/quill/index.ts +9 -0
  23. package/src/components/icons/icons.tsx +2 -0
  24. package/src/components/icons/index.ts +3 -0
  25. package/src/components/icons/lemon-brand-icons.tsx +16 -0
  26. package/src/components/icons/lemon-category-icons.tsx +72 -0
  27. package/src/components/icons/lemon-icons.tsx +57 -0
  28. package/src/components/sections/IconShowcase.tsx +110 -7
  29. package/src/components/sections/NewComponentsShowcase.tsx +1052 -0
  30. package/src/components/sections/ProductLayoutsShowcase.tsx +57 -117
  31. package/src/components/sections/QuillChartShowcase.tsx +92 -0
  32. package/src/components/sections/QuillDashboardShowcase.tsx +149 -0
  33. package/src/components/sections/SidebarShowcase.tsx +152 -0
  34. package/src/components/sections/TableShowcase.tsx +2 -0
  35. package/src/components/sections/TooltipShowcase.tsx +19 -25
  36. package/src/components/ui/AlertDialog.tsx +62 -0
  37. package/src/components/ui/AppLayout.tsx +27 -0
  38. package/src/components/ui/Autocomplete.tsx +106 -0
  39. package/src/components/ui/Banner.tsx +63 -0
  40. package/src/components/ui/ButtonGroup.tsx +16 -0
  41. package/src/components/ui/Chip.tsx +46 -0
  42. package/src/components/ui/Collapsible.tsx +46 -0
  43. package/src/components/ui/Combobox.tsx +119 -0
  44. package/src/components/ui/ContextMenu.tsx +81 -0
  45. package/src/components/ui/DataTable.tsx +133 -0
  46. package/src/components/ui/DatePicker.tsx +96 -0
  47. package/src/components/ui/Dialog.tsx +75 -0
  48. package/src/components/ui/Divider.tsx +39 -0
  49. package/src/components/ui/Dot.tsx +32 -0
  50. package/src/components/ui/InputGroup.tsx +16 -0
  51. package/src/components/ui/Lettermark.tsx +47 -0
  52. package/src/components/ui/Link.tsx +32 -0
  53. package/src/components/ui/LoadingBar.tsx +25 -0
  54. package/src/components/ui/Menubar.tsx +66 -0
  55. package/src/components/ui/Metric.tsx +34 -0
  56. package/src/components/ui/NumberField.tsx +76 -0
  57. package/src/components/ui/Popover.tsx +73 -0
  58. package/src/components/ui/ProgressCircle.tsx +63 -0
  59. package/src/components/ui/Resizable.tsx +71 -0
  60. package/src/components/ui/Row.tsx +51 -0
  61. package/src/components/ui/ScrollArea.tsx +54 -0
  62. package/src/components/ui/Sidebar.tsx +77 -0
  63. package/src/components/ui/Snack.tsx +48 -0
  64. package/src/components/ui/Spinner.tsx +30 -0
  65. package/src/components/ui/Splotch.tsx +33 -0
  66. package/src/components/ui/Table.tsx +2 -0
  67. package/src/components/ui/Tag.tsx +65 -0
  68. package/src/components/ui/Toggle.tsx +62 -0
  69. package/src/components/ui/Tooltip.test.tsx +58 -0
  70. package/src/components/ui/Tooltip.tsx +211 -0
  71. package/src/components/ui/Widget.tsx +31 -0
  72. package/src/components/ui/index.ts +275 -21
  73. package/dist/chunk-FDMD3IIN.mjs +0 -3831
  74. package/src/components/charts/ph-insight-charts.tsx +0 -162
  75. package/src/components/dashboard/DashboardFiltersBar.tsx +0 -19
  76. package/src/components/dashboard/DashboardGrid.tsx +0 -23
  77. package/src/components/dashboard/DashboardInsightPlaceholder.tsx +0 -37
  78. package/src/components/dashboard/DashboardKpiCard.tsx +0 -25
  79. package/src/components/dashboard/DashboardToolbar.tsx +0 -23
  80. package/src/components/dashboard/DashboardWell.tsx +0 -10
  81. package/src/components/dashboard/InsightMiniCard.tsx +0 -26
  82. package/src/components/dashboard/InsightShell.tsx +0 -37
  83. package/src/components/dashboard/InsightShellHeader.tsx +0 -22
  84. package/src/components/dashboard/MiniTrendBars.tsx +0 -51
  85. package/src/components/dashboard/index.ts +0 -31
  86. package/src/components/dashboard/types.ts +0 -9
  87. package/src/components/sections/ChartShowcase.tsx +0 -190
  88. package/src/components/sections/DashboardShowcase.tsx +0 -191
  89. 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";