@xenide-io/the-old-ui-theme 0.2.9 → 0.3.1

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 (133) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-5HSOBJ4F.mjs +5968 -0
  3. package/dist/index-BgG8Homu.d.mts +1461 -0
  4. package/dist/index-BgG8Homu.d.ts +1461 -0
  5. package/dist/index.d.mts +206 -100
  6. package/dist/index.d.ts +206 -100
  7. package/dist/index.js +4275 -1455
  8. package/dist/index.mjs +689 -735
  9. package/dist/tailwind-preset.js +7 -0
  10. package/dist/tailwind-preset.mjs +7 -0
  11. package/dist/ui.d.mts +3 -2
  12. package/dist/ui.d.ts +3 -2
  13. package/dist/ui.js +3449 -701
  14. package/dist/ui.mjs +106 -3
  15. package/package.json +20 -16
  16. package/src/app/globals.css +1953 -266
  17. package/src/components/charts/index.ts +9 -8
  18. package/src/components/charts/quill/BarChart.tsx +85 -0
  19. package/src/components/charts/quill/FunnelChart.tsx +49 -0
  20. package/src/components/charts/quill/InsightShell.tsx +27 -0
  21. package/src/components/charts/quill/MetricCard.tsx +44 -0
  22. package/src/components/charts/quill/PieChart.tsx +81 -0
  23. package/src/components/charts/quill/Sparkline.tsx +57 -0
  24. package/src/components/charts/quill/TimeSeriesLineChart.tsx +62 -0
  25. package/src/components/charts/quill/index.ts +9 -0
  26. package/src/components/icons/IconBase.tsx +20 -12
  27. package/src/components/icons/createIconBase.tsx +12 -7
  28. package/src/components/icons/icons.tsx +210 -18
  29. package/src/components/icons/index.ts +3 -0
  30. package/src/components/icons/lemon-brand-icons.tsx +16 -0
  31. package/src/components/icons/lemon-category-icons.tsx +72 -0
  32. package/src/components/icons/lemon-icons.tsx +57 -0
  33. package/src/components/sections/AlertShowcase.tsx +2 -1
  34. package/src/components/sections/BadgeShowcase.tsx +1 -1
  35. package/src/components/sections/ButtonShowcase.tsx +6 -10
  36. package/src/components/sections/CardShowcase.tsx +22 -6
  37. package/src/components/sections/DropdownShowcase.tsx +4 -6
  38. package/src/components/sections/FilterChipsShowcase.tsx +74 -26
  39. package/src/components/sections/FormShowcase.tsx +2 -1
  40. package/src/components/sections/IconShowcase.tsx +195 -212
  41. package/src/components/sections/KbdShowcase.tsx +47 -21
  42. package/src/components/sections/ModalShowcase.tsx +7 -6
  43. package/src/components/sections/NewComponentsShowcase.tsx +1052 -0
  44. package/src/components/sections/ProductLayoutsShowcase.tsx +78 -0
  45. package/src/components/sections/ProgressShowcase.tsx +50 -53
  46. package/src/components/sections/QuillChartShowcase.tsx +92 -0
  47. package/src/components/sections/QuillDashboardShowcase.tsx +149 -0
  48. package/src/components/sections/SidebarShowcase.tsx +151 -0
  49. package/src/components/sections/SwapShowcase.tsx +13 -5
  50. package/src/components/sections/TableShowcase.tsx +2 -0
  51. package/src/components/sections/TooltipShowcase.tsx +19 -25
  52. package/src/components/ui/Alert.tsx +28 -19
  53. package/src/components/ui/AlertDialog.tsx +62 -0
  54. package/src/components/ui/AppLayout.tsx +27 -0
  55. package/src/components/ui/AuthLayout.tsx +58 -0
  56. package/src/components/ui/Autocomplete.tsx +106 -0
  57. package/src/components/ui/Badge.tsx +10 -5
  58. package/src/components/ui/Banner.tsx +63 -0
  59. package/src/components/ui/Button.tsx +22 -12
  60. package/src/components/ui/ButtonChrome.tsx +1 -1
  61. package/src/components/ui/ButtonGroup.tsx +16 -0
  62. package/src/components/ui/Calendar.tsx +6 -7
  63. package/src/components/ui/Card.tsx +21 -11
  64. package/src/components/ui/Chip.tsx +46 -0
  65. package/src/components/ui/Collapsible.tsx +46 -0
  66. package/src/components/ui/CollapsibleSection.tsx +11 -11
  67. package/src/components/ui/Combobox.tsx +119 -0
  68. package/src/components/ui/ContextMenu.tsx +81 -0
  69. package/src/components/ui/DataTable.tsx +133 -0
  70. package/src/components/ui/DatePicker.tsx +96 -0
  71. package/src/components/ui/Dialog.tsx +75 -0
  72. package/src/components/ui/Divider.tsx +39 -0
  73. package/src/components/ui/Dot.tsx +32 -0
  74. package/src/components/ui/DropdownMenu.tsx +189 -65
  75. package/src/components/ui/FilterBar.tsx +165 -0
  76. package/src/components/ui/FilterChips.tsx +65 -35
  77. package/src/components/ui/FilterControls.tsx +30 -0
  78. package/src/components/ui/FormField.tsx +69 -0
  79. package/src/components/ui/Input.tsx +166 -140
  80. package/src/components/ui/InputGroup.tsx +16 -0
  81. package/src/components/ui/Kbd.tsx +88 -13
  82. package/src/components/ui/Lettermark.tsx +47 -0
  83. package/src/components/ui/Link.tsx +32 -0
  84. package/src/components/ui/Loader.tsx +96 -0
  85. package/src/components/ui/LoadingBar.tsx +25 -0
  86. package/src/components/ui/Menubar.tsx +66 -0
  87. package/src/components/ui/Metric.tsx +34 -0
  88. package/src/components/ui/Modal.tsx +66 -36
  89. package/src/components/ui/NumberField.tsx +76 -0
  90. package/src/components/ui/Popover.tsx +73 -0
  91. package/src/components/ui/ProgressCircle.tsx +63 -0
  92. package/src/components/ui/Resizable.tsx +71 -0
  93. package/src/components/ui/Row.tsx +51 -0
  94. package/src/components/ui/ScrollArea.tsx +54 -0
  95. package/src/components/ui/SettingsLayout.tsx +94 -0
  96. package/src/components/ui/ShowcaseWrapper.tsx +4 -16
  97. package/src/components/ui/Sidebar.tsx +77 -0
  98. package/src/components/ui/Snack.tsx +48 -0
  99. package/src/components/ui/Spinner.tsx +30 -0
  100. package/src/components/ui/Splotch.tsx +33 -0
  101. package/src/components/ui/Stepper.tsx +5 -6
  102. package/src/components/ui/Table.tsx +2 -0
  103. package/src/components/ui/Tag.tsx +65 -0
  104. package/src/components/ui/Toggle.tsx +62 -0
  105. package/src/components/ui/Tooltip.test.tsx +58 -0
  106. package/src/components/ui/Tooltip.tsx +211 -0
  107. package/src/components/ui/Widget.tsx +31 -0
  108. package/src/components/ui/first-slice.test.tsx +98 -0
  109. package/src/components/ui/index.ts +335 -22
  110. package/src/components/ui/interaction-primitives.test.tsx +77 -0
  111. package/src/components/ui/kbd-icons.test.tsx +90 -0
  112. package/src/components/ui/product-patterns.test.tsx +77 -0
  113. package/src/styles/themes.css +411 -1
  114. package/tailwind-preset.ts +7 -0
  115. package/dist/chunk-SHF57J7U.mjs +0 -2910
  116. package/dist/index-CmS6hcUk.d.mts +0 -753
  117. package/dist/index-CmS6hcUk.d.ts +0 -753
  118. package/src/components/charts/ph-insight-charts.tsx +0 -162
  119. package/src/components/dashboard/DashboardFiltersBar.tsx +0 -19
  120. package/src/components/dashboard/DashboardGrid.tsx +0 -23
  121. package/src/components/dashboard/DashboardInsightPlaceholder.tsx +0 -37
  122. package/src/components/dashboard/DashboardKpiCard.tsx +0 -25
  123. package/src/components/dashboard/DashboardToolbar.tsx +0 -23
  124. package/src/components/dashboard/DashboardWell.tsx +0 -10
  125. package/src/components/dashboard/InsightMiniCard.tsx +0 -26
  126. package/src/components/dashboard/InsightShell.tsx +0 -37
  127. package/src/components/dashboard/InsightShellHeader.tsx +0 -22
  128. package/src/components/dashboard/MiniTrendBars.tsx +0 -51
  129. package/src/components/dashboard/index.ts +0 -31
  130. package/src/components/dashboard/types.ts +0 -9
  131. package/src/components/sections/ChartShowcase.tsx +0 -190
  132. package/src/components/sections/DashboardShowcase.tsx +0 -191
  133. package/src/components/sections/StatShowcase.tsx +0 -129
@@ -1,4 +1,4 @@
1
- import type { ButtonHTMLAttributes, ReactNode } from "react";
1
+ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
2
2
  import { ButtonChrome } from "@/components/ui/ButtonChrome";
3
3
  import { cn } from "@/lib/cn";
4
4
 
@@ -29,6 +29,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
29
29
  icon?: ReactNode;
30
30
  sideIcon?: ReactNode;
31
31
  children?: ReactNode;
32
+ loading?: boolean;
33
+ loadingLabel?: ReactNode;
32
34
  }
33
35
 
34
36
  const variantClass: Record<ButtonVariant, string> = {
@@ -60,11 +62,8 @@ const shapeClass: Record<ButtonShape, string> = {
60
62
  wide: "ph-btn-wide",
61
63
  };
62
64
 
63
- /**
64
- * PostHog LemonButton parity primary/secondary use ::before border ring + ::after face/ridge
65
- * (see PostHog `LemonButton.scss`). Prefer this over raw `<button className="ph-btn …">`.
66
- */
67
- export function Button({
65
+ /** Filled variants keep a stationary depth plate while the face and content move together. */
66
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
68
67
  variant = "secondary",
69
68
  size = "md",
70
69
  shape = "default",
@@ -72,19 +71,24 @@ export function Button({
72
71
  split = false,
73
72
  icon,
74
73
  sideIcon,
74
+ loading = false,
75
+ loadingLabel,
75
76
  className,
76
77
  children,
77
78
  type = "button",
79
+ disabled,
78
80
  ...props
79
- }: ButtonProps) {
80
- const usesLemonChrome = variant === "primary" || variant === "secondary";
81
+ }, ref) {
82
+ const usesLemonChrome = !["tertiary", "ghost", "link"].includes(variant);
83
+ const label = loadingLabel ?? children;
84
+ const leadingIcon = loading ? <span className="ph-btn-spinner" aria-hidden /> : icon;
81
85
 
82
86
  const content = usesLemonChrome ? (
83
- <ButtonChrome label={children} icon={icon} sideIcon={sideIcon} split={split} />
87
+ <ButtonChrome label={label} icon={leadingIcon} sideIcon={sideIcon} split={split} />
84
88
  ) : (
85
89
  <>
86
- {icon != null ? <span className="ph-btn-icon">{icon}</span> : null}
87
- {children != null ? <span className="ph-btn-label">{children}</span> : null}
90
+ {leadingIcon != null ? <span className="ph-btn-icon">{leadingIcon}</span> : null}
91
+ {label != null ? <span className="ph-btn-label">{label}</span> : null}
88
92
  {split ? <span className="ph-btn-split-divider" aria-hidden /> : null}
89
93
  {sideIcon != null ? <span className="ph-btn-side-icon">{sideIcon}</span> : null}
90
94
  </>
@@ -92,9 +96,13 @@ export function Button({
92
96
 
93
97
  return (
94
98
  <button
99
+ ref={ref}
95
100
  type={type}
101
+ disabled={disabled || loading}
102
+ aria-busy={loading || undefined}
96
103
  className={cn(
97
104
  "ph-btn",
105
+ usesLemonChrome && "ph-btn-raised",
98
106
  outline && "ph-btn-outline",
99
107
  variantClass[variant],
100
108
  sizeClass[size],
@@ -107,4 +115,6 @@ export function Button({
107
115
  {content}
108
116
  </button>
109
117
  );
110
- }
118
+ });
119
+
120
+ Button.displayName = "Button";
@@ -7,7 +7,7 @@ export interface ButtonChromeProps {
7
7
  split?: boolean;
8
8
  }
9
9
 
10
- /** Inner face for Lemon primary/secondary keeps labels above ::after ridge. */
10
+ /** Content layer for tactile variants; CSS moves it with the independently rendered face. */
11
11
  export function ButtonChrome({ label, icon, sideIcon, split }: ButtonChromeProps) {
12
12
  return (
13
13
  <span className="ph-btn-chrome">
@@ -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";
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { IconChevronLeft, IconChevronRight } from "@/components/icons";
4
5
  import { cn } from "@/lib/cn";
5
6
 
6
7
  interface CalendarProps {
@@ -64,11 +65,10 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
64
65
  <button
65
66
  type="button"
66
67
  onClick={prevMonth}
68
+ aria-label="Previous month"
67
69
  className="rounded-lg p-1 text-ph-subtle transition hover:bg-ph-muted hover:text-ph-ink"
68
70
  >
69
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
70
- <path d="M15 18l-6-6 6-6" />
71
- </svg>
71
+ <IconChevronLeft className="h-5 w-5" aria-hidden />
72
72
  </button>
73
73
  <span className="text-sm font-semibold text-ph-ink">
74
74
  {monthNames[month]} {year}
@@ -76,11 +76,10 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
76
76
  <button
77
77
  type="button"
78
78
  onClick={nextMonth}
79
+ aria-label="Next month"
79
80
  className="rounded-lg p-1 text-ph-subtle transition hover:bg-ph-muted hover:text-ph-ink"
80
81
  >
81
- <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
82
- <path d="M9 18l6-6-6-6" />
83
- </svg>
82
+ <IconChevronRight className="h-5 w-5" aria-hidden />
84
83
  </button>
85
84
  </div>
86
85
 
@@ -99,7 +98,7 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
99
98
  className={cn(
100
99
  "flex h-full w-full items-center justify-center rounded-lg text-sm transition-colors",
101
100
  isSelected(day)
102
- ? "bg-ph-brand font-semibold text-white shadow-ph"
101
+ ? "bg-ph-brand font-semibold text-[var(--ph-on-accent)] shadow-ph"
103
102
  : isToday(day)
104
103
  ? "bg-ph-muted font-semibold text-ph-brand ring-1 ring-ph-brand/30"
105
104
  : "text-ph-ink hover:bg-ph-muted"
@@ -1,9 +1,9 @@
1
- import type { ReactNode } from "react";
1
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
2
2
  import { cn } from "@/lib/cn";
3
3
 
4
4
  export type CardVariant = "default" | "outlined" | "elevated" | "highlighted";
5
5
 
6
- export interface CardProps {
6
+ export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
7
7
  children?: ReactNode;
8
8
  title?: ReactNode;
9
9
  description?: ReactNode;
@@ -13,16 +13,18 @@ export interface CardProps {
13
13
  variant?: CardVariant;
14
14
  className?: string;
15
15
  bodyClassName?: string;
16
+ interactive?: boolean;
17
+ titleAs?: "h2" | "h3" | "h4";
16
18
  }
17
19
 
18
20
  const variantMap: Record<CardVariant, string> = {
19
- default: "bg-ph-surface",
20
- outlined: "border-2 border-ph-border bg-ph-surface",
21
- elevated: "bg-ph-surface shadow-ph-md",
22
- highlighted: "border border-ph-brand/35 bg-orange-50/40 shadow-ph",
21
+ default: "",
22
+ outlined: "border-2 border-ph-border",
23
+ elevated: "ph-card-elevated",
24
+ highlighted: "ph-card-highlighted",
23
25
  };
24
26
 
25
- export function Card({
27
+ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card({
26
28
  children,
27
29
  title,
28
30
  description,
@@ -32,19 +34,25 @@ export function Card({
32
34
  variant = "default",
33
35
  className,
34
36
  bodyClassName,
35
- }: CardProps) {
37
+ interactive = false,
38
+ titleAs: Title = "h3",
39
+ ...props
40
+ }, ref) {
36
41
  return (
37
42
  <div
43
+ ref={ref}
38
44
  className={cn(
39
45
  "ph-card",
40
46
  variantMap[variant],
47
+ interactive && "ph-card-interactive",
41
48
  className
42
49
  )}
50
+ {...props}
43
51
  >
44
- {media && <div className="-mx-6 -mt-6 mb-4">{media}</div>}
52
+ {media && <div className="ph-card-media">{media}</div>}
45
53
  <div className={cn("ph-card-body", bodyClassName)}>
46
54
  {title && (
47
- <h3 className="ph-card-title">{title}</h3>
55
+ <Title className="ph-card-title">{title}</Title>
48
56
  )}
49
57
  {description && (
50
58
  <p className="text-sm text-ph-subtle">{description}</p>
@@ -57,4 +65,6 @@ export function Card({
57
65
  )}
58
66
  </div>
59
67
  );
60
- }
68
+ });
69
+
70
+ Card.displayName = "Card";
@@ -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";
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useId, useState } from "react";
4
+ import { IconChevronDown } from "@/components/icons";
4
5
  import { cn } from "@/lib/cn";
5
6
 
6
7
  interface CollapsibleSectionProps {
@@ -12,31 +13,30 @@ interface CollapsibleSectionProps {
12
13
 
13
14
  export function CollapsibleSection({ title, children, defaultOpen = true, id }: CollapsibleSectionProps) {
14
15
  const [isOpen, setIsOpen] = useState(defaultOpen);
16
+ const generatedId = useId();
17
+ const contentId = `${id ?? generatedId}-content`;
15
18
 
16
19
  return (
17
20
  <section id={id} className="scroll-mt-20">
18
21
  <button
19
22
  type="button"
20
23
  onClick={() => setIsOpen(!isOpen)}
24
+ aria-expanded={isOpen}
25
+ aria-controls={contentId}
21
26
  className="mb-6 flex w-full items-center justify-between rounded-lg border border-ph-border bg-ph-muted px-5 py-4 text-left transition hover:bg-ph-muted/80"
22
27
  >
23
28
  <span className="text-lg font-bold text-ph-ink">{title}</span>
24
- <svg
29
+ <IconChevronDown
25
30
  className={cn(
26
31
  "h-5 w-5 text-ph-subtle transition-transform duration-200",
27
32
  isOpen && "rotate-180"
28
33
  )}
29
- viewBox="0 0 24 24"
30
- fill="none"
31
- stroke="currentColor"
32
- strokeWidth="2"
33
- strokeLinecap="round"
34
- strokeLinejoin="round"
35
- >
36
- <path d="M6 9l6 6 6-6" />
37
- </svg>
34
+ aria-hidden
35
+ />
38
36
  </button>
39
37
  <div
38
+ id={contentId}
39
+ aria-hidden={!isOpen}
40
40
  className={cn(
41
41
  "overflow-hidden transition-all duration-300",
42
42
  isOpen ? "max-h-[50000px] opacity-100" : "max-h-0 opacity-0"
@@ -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";