@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
@@ -2,37 +2,112 @@ import type { HTMLAttributes, ReactNode } from "react";
2
2
  import { cn } from "@/lib/cn";
3
3
 
4
4
  export type KbdVariant = "shortcut" | "key" | "token";
5
+ export type KbdPlatform = "text" | "mac" | "windows";
5
6
 
6
7
  export interface KbdProps extends HTMLAttributes<HTMLElement> {
7
8
  variant?: KbdVariant;
8
- keys?: ReactNode[];
9
+ keys?: readonly ReactNode[];
10
+ platform?: KbdPlatform;
11
+ separator?: ReactNode;
9
12
  children?: ReactNode;
10
13
  }
11
14
 
12
- export function Kbd({ variant = "shortcut", keys, children, className, ...props }: KbdProps) {
15
+ const platformLabels: Record<KbdPlatform, Record<string, string>> = {
16
+ text: {
17
+ command: "Command",
18
+ cmd: "Command",
19
+ option: "Option",
20
+ alt: "Alt",
21
+ control: "Control",
22
+ ctrl: "Control",
23
+ shift: "Shift",
24
+ },
25
+ mac: {
26
+ command: "⌘",
27
+ cmd: "⌘",
28
+ option: "⌥",
29
+ alt: "⌥",
30
+ control: "⌃",
31
+ ctrl: "⌃",
32
+ shift: "⇧",
33
+ },
34
+ windows: {
35
+ command: "Win",
36
+ cmd: "Win",
37
+ option: "Alt",
38
+ alt: "Alt",
39
+ control: "Ctrl",
40
+ ctrl: "Ctrl",
41
+ shift: "Shift",
42
+ },
43
+ };
44
+
45
+ const spokenLabels: Record<string, string> = {
46
+ "⌘": "Command",
47
+ "⌥": "Option",
48
+ "⌃": "Control",
49
+ "⇧": "Shift",
50
+ "↵": "Enter",
51
+ "⏎": "Enter",
52
+ "⌫": "Backspace",
53
+ "⎋": "Escape",
54
+ };
55
+
56
+ export function Kbd({
57
+ variant = "shortcut",
58
+ keys,
59
+ platform = "text",
60
+ separator,
61
+ children,
62
+ className,
63
+ "aria-label": ariaLabel,
64
+ ...props
65
+ }: KbdProps) {
13
66
  if (variant === "token") {
14
67
  return (
15
- <code className={cn("ph-kbd text-ph-ink", className)} {...props}>
68
+ <code className={cn("ph-code-token ph-kbd", className)} aria-label={ariaLabel} {...props}>
16
69
  {children}
17
70
  </code>
18
71
  );
19
72
  }
20
73
 
21
- const content = keys?.length ? keys.join(" ") : normalizeShortcut(children);
74
+ const sourceKeys = keys?.length ? keys : children != null ? [children] : [];
75
+ const displayKeys = sourceKeys.map((key) => formatKey(key, platform));
76
+ const accessibleLabel = ariaLabel ?? getShortcutLabel(displayKeys);
77
+
78
+ if (variant === "key") {
79
+ return (
80
+ <kbd className={cn("ph-keycap", className)} aria-label={accessibleLabel} {...props}>
81
+ {displayKeys[0]}
82
+ </kbd>
83
+ );
84
+ }
22
85
 
23
86
  return (
24
- <kbd className={cn("ph-shortcut", className)} {...props}>
25
- {content}
87
+ <kbd className={cn("ph-shortcut-group ph-keycap", className)} aria-label={accessibleLabel} {...props}>
88
+ {displayKeys.map((key, index) => (
89
+ <span key={index} className="contents">
90
+ {index > 0 && separator != null ? <span className="ph-shortcut-separator" aria-hidden="true">{separator}</span> : null}
91
+ <span className="ph-shortcut-key" aria-hidden={accessibleLabel ? true : undefined}>{key}</span>
92
+ </span>
93
+ ))}
26
94
  </kbd>
27
95
  );
28
96
  }
29
97
 
30
- function normalizeShortcut(children: ReactNode) {
31
- if (typeof children !== "string") return children;
98
+ function formatKey(key: ReactNode, platform: KbdPlatform) {
99
+ if (typeof key !== "string") return key;
100
+ return platformLabels[platform][key.toLowerCase()] ?? key;
101
+ }
102
+
103
+ function getShortcutLabel(keys: ReactNode[]) {
104
+ const labels = keys.map((key) => {
105
+ if (typeof key !== "string" && typeof key !== "number") return null;
106
+ const value = String(key);
107
+ return spokenLabels[value] ?? value;
108
+ });
32
109
 
33
- return children
34
- .replace(/\bCommand\b|\bCmd\b/g, "⌘")
35
- .replace(/\bOption\b|\bAlt\b/g, "⌥")
36
- .replace(/\bShift\b/g, "⇧")
37
- .replace(/\bControl\b|\bCtrl\b/g, "⌃");
110
+ return labels.every((label): label is string => label != null)
111
+ ? labels.join(" + ")
112
+ : undefined;
38
113
  }
@@ -0,0 +1,47 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface LettermarkProps extends HTMLAttributes<HTMLDivElement> {
5
+ name?: string | number | null;
6
+ index?: number;
7
+ rounded?: boolean;
8
+ outlined?: boolean;
9
+ size?: "xs" | "sm" | "md" | "lg";
10
+ }
11
+
12
+ export const Lettermark = forwardRef<HTMLDivElement, LettermarkProps>(function Lettermark({
13
+ name,
14
+ index,
15
+ outlined = false,
16
+ rounded = false,
17
+ size = "md",
18
+ className,
19
+ ...props
20
+ }, ref) {
21
+ const letter = name
22
+ ? typeof name === "number"
23
+ ? String(Math.floor(name))
24
+ : String.fromCodePoint(name.codePointAt(0)!).toLocaleUpperCase()
25
+ : "?";
26
+
27
+ return (
28
+ <div
29
+ ref={ref}
30
+ className={cn(
31
+ "ph-lettermark",
32
+ `ph-lettermark--${size}`,
33
+ typeof index === "number" && `ph-lettermark--v-${(index % 8) + 1}`,
34
+ outlined && "ph-lettermark--outlined",
35
+ rounded && "ph-lettermark--rounded",
36
+ letter === "?" && "ph-lettermark--unknown",
37
+ className
38
+ )}
39
+ aria-hidden="true"
40
+ {...props}
41
+ >
42
+ {letter}
43
+ </div>
44
+ );
45
+ });
46
+
47
+ Lettermark.displayName = "Lettermark";
@@ -0,0 +1,32 @@
1
+ import { forwardRef, type AnchorHTMLAttributes, type ReactNode } from "react";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
5
+ children: ReactNode;
6
+ disabled?: boolean;
7
+ }
8
+
9
+ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link({
10
+ className,
11
+ disabled = false,
12
+ children,
13
+ ...props
14
+ }, ref) {
15
+ return (
16
+ <a
17
+ ref={ref}
18
+ className={cn(
19
+ "ph-link",
20
+ disabled && "ph-link--disabled pointer-events-none opacity-50",
21
+ className
22
+ )}
23
+ tabIndex={disabled ? -1 : undefined}
24
+ aria-disabled={disabled || undefined}
25
+ {...props}
26
+ >
27
+ {children}
28
+ </a>
29
+ );
30
+ });
31
+
32
+ Link.displayName = "Link";
@@ -0,0 +1,96 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export type LoaderVariant = "spinner" | "dots" | "pulse";
5
+ export type LoaderSize = "sm" | "md" | "lg";
6
+ export type SkeletonShape = "title" | "text" | "short-text" | "avatar" | "button" | "block";
7
+
8
+ export interface LoaderProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
9
+ variant?: LoaderVariant;
10
+ size?: LoaderSize;
11
+ label?: string;
12
+ }
13
+
14
+ export interface SkeletonProps extends ComponentPropsWithoutRef<"span"> {
15
+ shape?: SkeletonShape;
16
+ }
17
+
18
+ export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
19
+ label?: string;
20
+ title?: ReactNode;
21
+ description?: ReactNode;
22
+ variant?: LoaderVariant;
23
+ size?: LoaderSize;
24
+ }
25
+
26
+ const loaderSizeClass: Record<LoaderSize, string> = {
27
+ sm: "ph-loader-sm",
28
+ md: "ph-loader-md",
29
+ lg: "ph-loader-lg",
30
+ };
31
+
32
+ const skeletonShapeClass: Record<SkeletonShape, string> = {
33
+ title: "ph-skeleton-title",
34
+ text: "ph-skeleton-text",
35
+ "short-text": "ph-skeleton-text-short",
36
+ avatar: "ph-skeleton-avatar",
37
+ button: "ph-skeleton-button",
38
+ block: "ph-skeleton-block",
39
+ };
40
+
41
+ export function Loader({
42
+ variant = "spinner",
43
+ size = "md",
44
+ label = "Loading",
45
+ className,
46
+ ...props
47
+ }: LoaderProps) {
48
+ return (
49
+ <span
50
+ role="status"
51
+ aria-label={label}
52
+ className={cn("ph-loader", loaderSizeClass[size], className)}
53
+ {...props}
54
+ >
55
+ {variant === "spinner" ? <span className="ph-loading" aria-hidden /> : null}
56
+ {variant === "dots" ? (
57
+ <span className="ph-loading-dots" aria-hidden>
58
+ <span />
59
+ <span />
60
+ <span />
61
+ </span>
62
+ ) : null}
63
+ {variant === "pulse" ? <span className="ph-loading-pulse" aria-hidden /> : null}
64
+ </span>
65
+ );
66
+ }
67
+
68
+ export function Skeleton({ shape = "text", className, ...props }: SkeletonProps) {
69
+ return (
70
+ <span
71
+ className={cn("ph-skeleton", skeletonShapeClass[shape], className)}
72
+ aria-hidden="true"
73
+ {...props}
74
+ />
75
+ );
76
+ }
77
+
78
+ export function LoadingState({
79
+ label = "Loading",
80
+ title = "Loading",
81
+ description,
82
+ variant = "spinner",
83
+ size = "lg",
84
+ className,
85
+ ...props
86
+ }: LoadingStateProps) {
87
+ return (
88
+ <div className={cn("ph-loading-state", className)} role="status" aria-label={label} {...props}>
89
+ <Loader variant={variant} size={size} label={label} aria-hidden="true" />
90
+ <div className="ph-loading-state__copy">
91
+ <strong>{title}</strong>
92
+ {description ? <span>{description}</span> : null}
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,25 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface LoadingBarProps extends HTMLAttributes<HTMLDivElement> {
5
+ active?: boolean;
6
+ }
7
+
8
+ export function LoadingBar({ active = true, className, ...props }: LoadingBarProps) {
9
+ return (
10
+ <div
11
+ className={cn(
12
+ "ph-loading-bar",
13
+ active && "ph-loading-bar--active",
14
+ className
15
+ )}
16
+ role="progressbar"
17
+ aria-label="Loading"
18
+ {...props}
19
+ >
20
+ <div className="ph-loading-bar__fill" />
21
+ </div>
22
+ );
23
+ }
24
+
25
+ LoadingBar.displayName = "LoadingBar";
@@ -0,0 +1,66 @@
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 MenubarItem {
7
+ label: string;
8
+ items?: { label: string; onClick?: () => void; disabled?: boolean; separator?: boolean }[];
9
+ }
10
+
11
+ export interface MenubarProps extends HTMLAttributes<HTMLDivElement> {
12
+ items: MenubarItem[];
13
+ }
14
+
15
+ export function Menubar({ items, className, ...props }: MenubarProps) {
16
+ const [openIndex, setOpenIndex] = useState(-1);
17
+ const barRef = useRef<HTMLDivElement>(null);
18
+
19
+ useEffect(() => {
20
+ if (openIndex < 0) return;
21
+ const handle = (e: MouseEvent) => {
22
+ if (barRef.current && !barRef.current.contains(e.target as Node)) {
23
+ setOpenIndex(-1);
24
+ }
25
+ };
26
+ document.addEventListener("mousedown", handle);
27
+ return () => document.removeEventListener("mousedown", handle);
28
+ }, [openIndex]);
29
+
30
+ return (
31
+ <div ref={barRef} className={cn("ph-menubar", className)} {...props}>
32
+ {items.map((item, i) => (
33
+ <div key={i} className="ph-menubar__item">
34
+ <button
35
+ type="button"
36
+ onClick={() => setOpenIndex(openIndex === i ? -1 : i)}
37
+ className={cn("ph-menubar__trigger", openIndex === i && "ph-menubar__trigger--open")}
38
+ >
39
+ {item.label}
40
+ </button>
41
+ {openIndex === i && item.items && (
42
+ <div className="ph-menubar__dropdown">
43
+ {item.items.map((sub, j) =>
44
+ sub.separator ? (
45
+ <div key={j} className="ph-menubar__separator" />
46
+ ) : (
47
+ <button
48
+ key={j}
49
+ type="button"
50
+ disabled={sub.disabled}
51
+ onClick={() => { sub.onClick?.(); setOpenIndex(-1); }}
52
+ className="ph-menubar__sub-item"
53
+ >
54
+ {sub.label}
55
+ </button>
56
+ )
57
+ )}
58
+ </div>
59
+ )}
60
+ </div>
61
+ ))}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ Menubar.displayName = "Menubar";
@@ -0,0 +1,34 @@
1
+ import { type ReactNode, type HTMLAttributes } from "react";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface MetricProps extends HTMLAttributes<HTMLDivElement> {
5
+ value: string | number;
6
+ label: string;
7
+ badge?: ReactNode;
8
+ trend?: ReactNode;
9
+ icon?: ReactNode;
10
+ }
11
+
12
+ export function Metric({
13
+ value,
14
+ label,
15
+ badge,
16
+ trend,
17
+ icon,
18
+ className,
19
+ ...props
20
+ }: MetricProps) {
21
+ return (
22
+ <div className={cn("ph-metric", className)} {...props}>
23
+ <div className="ph-metric__header">
24
+ {icon && <span className="ph-metric__icon">{icon}</span>}
25
+ <span className="ph-metric__label">{label}</span>
26
+ {badge && <span className="ph-metric__badge">{badge}</span>}
27
+ </div>
28
+ <div className="ph-metric__value">{value}</div>
29
+ {trend && <div className="ph-metric__trend">{trend}</div>}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ Metric.displayName = "Metric";
@@ -1,4 +1,7 @@
1
- import type { ReactNode } from "react";
1
+ "use client";
2
+
3
+ import * as Dialog from "@radix-ui/react-dialog";
4
+ import { useRef, type ReactNode } from "react";
2
5
  import { IconClose } from "@/components/icons";
3
6
  import { cn } from "@/lib/cn";
4
7
 
@@ -14,6 +17,9 @@ export interface ModalProps {
14
17
  size?: ModalSize;
15
18
  className?: string;
16
19
  showCloseButton?: boolean;
20
+ closeOnOutsideClick?: boolean;
21
+ closeOnEscape?: boolean;
22
+ "aria-label"?: string;
17
23
  }
18
24
 
19
25
  const sizeMap: Record<ModalSize, string> = {
@@ -21,7 +27,7 @@ const sizeMap: Record<ModalSize, string> = {
21
27
  md: "max-w-lg",
22
28
  lg: "max-w-2xl",
23
29
  xl: "max-w-4xl",
24
- full: "max-w-[calc(100%-2rem)]",
30
+ full: "max-w-none",
25
31
  };
26
32
 
27
33
  export function Modal({
@@ -34,42 +40,66 @@ export function Modal({
34
40
  size = "md",
35
41
  className,
36
42
  showCloseButton = true,
43
+ closeOnOutsideClick = true,
44
+ closeOnEscape = true,
45
+ "aria-label": ariaLabel,
37
46
  }: ModalProps) {
38
- if (!open) return null;
47
+ const canDismiss = Boolean(onClose);
48
+ const restoreFocusRef = useRef<HTMLElement | null>(null);
39
49
 
40
50
  return (
41
- <div className="ph-modal-overlay" role="presentation" onClick={onClose}>
42
- <div
43
- role="dialog"
44
- aria-modal
45
- className={cn(
46
- "ph-modal-panel",
47
- sizeMap[size],
48
- className
49
- )}
50
- onClick={(e) => e.stopPropagation()}
51
- >
52
- {(title || showCloseButton) && (
53
- <header className="flex items-start justify-between gap-4">
54
- <div>
55
- {title && <h3 className="text-lg font-bold text-ph-ink">{title}</h3>}
56
- {description && <p className="mt-1 text-sm text-ph-subtle">{description}</p>}
57
- </div>
58
- {showCloseButton && onClose && (
59
- <button
60
- type="button"
61
- onClick={onClose}
62
- className="shrink-0 rounded-md p-1 text-ph-subtle hover:bg-black/5 hover:text-ph-ink"
63
- aria-label="Close"
64
- >
65
- <IconClose className="h-5 w-5" />
66
- </button>
67
- )}
68
- </header>
69
- )}
70
- <div className="mt-4">{children}</div>
71
- {footer && <div className="mt-6 flex justify-end gap-2 border-t border-ph-border pt-5">{footer}</div>}
72
- </div>
73
- </div>
51
+ <Dialog.Root
52
+ open={open}
53
+ onOpenChange={(nextOpen) => {
54
+ if (!nextOpen) onClose?.();
55
+ }}
56
+ >
57
+ <Dialog.Portal>
58
+ <Dialog.Overlay className="ph-modal-overlay" />
59
+ <Dialog.Content
60
+ className={cn("ph-modal-panel", sizeMap[size], className)}
61
+ aria-label={ariaLabel}
62
+ {...(description ? {} : { "aria-describedby": undefined })}
63
+ onOpenAutoFocus={() => {
64
+ restoreFocusRef.current = document.activeElement instanceof HTMLElement
65
+ ? document.activeElement
66
+ : null;
67
+ }}
68
+ onCloseAutoFocus={(event) => {
69
+ event.preventDefault();
70
+ restoreFocusRef.current?.focus();
71
+ }}
72
+ onEscapeKeyDown={(event) => {
73
+ if (!canDismiss || !closeOnEscape) event.preventDefault();
74
+ }}
75
+ onPointerDownOutside={(event) => {
76
+ if (!canDismiss || !closeOnOutsideClick) event.preventDefault();
77
+ }}
78
+ >
79
+ {title ? null : <Dialog.Title className="sr-only">{ariaLabel ?? "Dialog"}</Dialog.Title>}
80
+ {(title || description || (showCloseButton && canDismiss)) ? (
81
+ <header className="ph-modal-header">
82
+ <div className="min-w-0">
83
+ {title ? <Dialog.Title className="text-lg font-bold text-ph-ink">{title}</Dialog.Title> : null}
84
+ {description ? (
85
+ <Dialog.Description className="mt-1 text-sm text-ph-subtle">
86
+ {description}
87
+ </Dialog.Description>
88
+ ) : null}
89
+ </div>
90
+ {showCloseButton && canDismiss ? (
91
+ <Dialog.Close asChild>
92
+ <button type="button" className="ph-modal-close" aria-label="Close dialog">
93
+ <IconClose className="h-5 w-5" aria-hidden />
94
+ </button>
95
+ </Dialog.Close>
96
+ ) : null}
97
+ </header>
98
+ ) : null}
99
+ <div className="ph-modal-body">{children}</div>
100
+ {footer ? <div className="ph-modal-footer">{footer}</div> : null}
101
+ </Dialog.Content>
102
+ </Dialog.Portal>
103
+ </Dialog.Root>
74
104
  );
75
105
  }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useState, type InputHTMLAttributes } from "react";
4
+ import { cn } from "@/lib/cn";
5
+
6
+ export interface NumberFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> {
7
+ value?: number;
8
+ onChange?: (value: number) => void;
9
+ min?: number;
10
+ max?: number;
11
+ step?: number;
12
+ label?: string;
13
+ }
14
+
15
+ export function NumberField({
16
+ value: controlledValue,
17
+ onChange,
18
+ min,
19
+ max,
20
+ step = 1,
21
+ label,
22
+ className,
23
+ disabled,
24
+ defaultValue,
25
+ ...inputProps
26
+ }: NumberFieldProps) {
27
+ const [internal, setInternal] = useState(controlledValue ?? 0);
28
+ const value = controlledValue ?? internal;
29
+
30
+ const set = (v: number) => {
31
+ const clamped = Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v));
32
+ setInternal(clamped);
33
+ onChange?.(clamped);
34
+ };
35
+
36
+ return (
37
+ <div className={cn("ph-number-field", className)}>
38
+ {label && <label className="ph-label">{label}</label>}
39
+ <div className="ph-number-field__control">
40
+ <button
41
+ type="button"
42
+ disabled={disabled || (min !== undefined && value <= min)}
43
+ onClick={() => set(value - step)}
44
+ className="ph-number-field__btn"
45
+ aria-label="Decrease"
46
+ >
47
+ <svg width="12" height="2" viewBox="0 0 12 2" fill="currentColor"><rect y="0" width="12" height="2" rx="1" /></svg>
48
+ </button>
49
+ <input
50
+ type="number"
51
+ value={value}
52
+ onChange={(e) => set(Number(e.target.value))}
53
+ disabled={disabled}
54
+ min={min}
55
+ max={max}
56
+ step={step}
57
+ className="ph-number-field__input"
58
+ {...inputProps}
59
+ />
60
+ <button
61
+ type="button"
62
+ disabled={disabled || (max !== undefined && value >= max)}
63
+ onClick={() => set(value + step)}
64
+ className="ph-number-field__btn"
65
+ aria-label="Increase"
66
+ >
67
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
68
+ <path d="M6 2v8M2 6h8" />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ NumberField.displayName = "NumberField";