@xenide-io/the-old-ui-theme 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chunk-FDMD3IIN.mjs +3831 -0
- package/dist/index-D1RTT2Ap.d.mts +1044 -0
- package/dist/index-D1RTT2Ap.d.ts +1044 -0
- package/dist/index.d.mts +11 -758
- package/dist/index.d.ts +11 -758
- package/dist/index.js +1749 -768
- package/dist/index.mjs +312 -2846
- package/dist/tailwind-preset.js +7 -0
- package/dist/tailwind-preset.mjs +7 -0
- package/dist/ui.d.mts +4 -0
- package/dist/ui.d.ts +4 -0
- package/dist/ui.js +3864 -0
- package/dist/ui.mjs +169 -0
- package/package.json +28 -6
- package/src/app/globals.css +1005 -227
- package/src/components/charts/index.ts +8 -0
- package/src/components/charts/ph-insight-charts.tsx +162 -0
- package/src/components/dashboard/DashboardFiltersBar.tsx +19 -0
- package/src/components/dashboard/DashboardGrid.tsx +23 -0
- package/src/components/dashboard/DashboardInsightPlaceholder.tsx +37 -0
- package/src/components/dashboard/DashboardKpiCard.tsx +25 -0
- package/src/components/dashboard/DashboardToolbar.tsx +23 -0
- package/src/components/dashboard/DashboardWell.tsx +10 -0
- package/src/components/dashboard/InsightMiniCard.tsx +26 -0
- package/src/components/dashboard/InsightShell.tsx +37 -0
- package/src/components/dashboard/InsightShellHeader.tsx +22 -0
- package/src/components/dashboard/MiniTrendBars.tsx +51 -0
- package/src/components/dashboard/index.ts +31 -0
- package/src/components/dashboard/types.ts +9 -0
- package/src/components/icons/IconBase.tsx +47 -0
- package/src/components/icons/createIconBase.tsx +32 -0
- package/src/components/icons/icons.tsx +557 -0
- package/src/components/icons/index.ts +3 -0
- package/src/components/sections/AccordionShowcase.tsx +45 -0
- package/src/components/sections/AlertShowcase.tsx +40 -0
- package/src/components/sections/AvatarShowcase.tsx +80 -0
- package/src/components/sections/BadgeShowcase.tsx +65 -0
- package/src/components/sections/ButtonShowcase.tsx +308 -0
- package/src/components/sections/CalendarShowcase.tsx +35 -0
- package/src/components/sections/CardShowcase.tsx +159 -0
- package/src/components/sections/ChartShowcase.tsx +190 -0
- package/src/components/sections/CodeMockupShowcase.tsx +56 -0
- package/src/components/sections/ColorPalette.tsx +117 -0
- package/src/components/sections/CommandPaletteShowcase.tsx +48 -0
- package/src/components/sections/CountdownShowcase.tsx +43 -0
- package/src/components/sections/DashboardShowcase.tsx +191 -0
- package/src/components/sections/DiffShowcase.tsx +70 -0
- package/src/components/sections/DrawerShowcase.tsx +97 -0
- package/src/components/sections/DropdownShowcase.tsx +98 -0
- package/src/components/sections/EmptyStateShowcase.tsx +50 -0
- package/src/components/sections/FilterChipsShowcase.tsx +98 -0
- package/src/components/sections/FormShowcase.tsx +127 -0
- package/src/components/sections/HoverCardShowcase.tsx +50 -0
- package/src/components/sections/IconShowcase.tsx +127 -0
- package/src/components/sections/IndicatorShowcase.tsx +52 -0
- package/src/components/sections/KbdShowcase.tsx +57 -0
- package/src/components/sections/ModalShowcase.tsx +211 -0
- package/src/components/sections/NavigationShowcase.tsx +199 -0
- package/src/components/sections/ProductLayoutsShowcase.tsx +138 -0
- package/src/components/sections/ProgressShowcase.tsx +82 -0
- package/src/components/sections/SegmentedControlShowcase.tsx +49 -0
- package/src/components/sections/SetupThemeShowcase.tsx +262 -0
- package/src/components/sections/StackShowcase.tsx +33 -0
- package/src/components/sections/StatShowcase.tsx +129 -0
- package/src/components/sections/StepperShowcase.tsx +37 -0
- package/src/components/sections/SwapShowcase.tsx +99 -0
- package/src/components/sections/TabShowcase.tsx +84 -0
- package/src/components/sections/TableShowcase.tsx +140 -0
- package/src/components/sections/TimelineShowcase.tsx +83 -0
- package/src/components/sections/ToastShowcase.tsx +108 -0
- package/src/components/sections/TooltipShowcase.tsx +44 -0
- package/src/components/sections/UtilityShowcase.tsx +81 -0
- package/src/components/ui/Accordion.tsx +77 -0
- package/src/components/ui/Alert.tsx +77 -0
- package/src/components/ui/AuthLayout.tsx +58 -0
- package/src/components/ui/Avatar.tsx +97 -0
- package/src/components/ui/Badge.tsx +60 -0
- package/src/components/ui/Button.tsx +120 -0
- package/src/components/ui/ButtonChrome.tsx +20 -0
- package/src/components/ui/Calendar.tsx +115 -0
- package/src/components/ui/Card.tsx +70 -0
- package/src/components/ui/ChatBubble.tsx +71 -0
- package/src/components/ui/CodeBlock.tsx +48 -0
- package/src/components/ui/CollapsibleSection.tsx +49 -0
- package/src/components/ui/CommandPalette.tsx +137 -0
- package/src/components/ui/ComponentDocs.tsx +52 -0
- package/src/components/ui/Drawer.tsx +71 -0
- package/src/components/ui/DropdownMenu.tsx +262 -0
- package/src/components/ui/EmptyState.tsx +33 -0
- package/src/components/ui/FilterBar.tsx +165 -0
- package/src/components/ui/FilterChips.tsx +80 -0
- package/src/components/ui/FilterControls.tsx +30 -0
- package/src/components/ui/FormField.tsx +69 -0
- package/src/components/ui/HoverCard.tsx +36 -0
- package/src/components/ui/Indicator.tsx +61 -0
- package/src/components/ui/Input.tsx +385 -0
- package/src/components/ui/Kbd.tsx +113 -0
- package/src/components/ui/Loader.tsx +96 -0
- package/src/components/ui/Modal.tsx +105 -0
- package/src/components/ui/Navigation.tsx +94 -0
- package/src/components/ui/Panel.tsx +18 -0
- package/src/components/ui/Progress.tsx +59 -0
- package/src/components/ui/Rating.tsx +65 -0
- package/src/components/ui/SearchInput.tsx +106 -0
- package/src/components/ui/SegmentedControl.tsx +44 -0
- package/src/components/ui/SettingsLayout.tsx +94 -0
- package/src/components/ui/ShowcaseWrapper.tsx +124 -0
- package/src/components/ui/Stat.tsx +39 -0
- package/src/components/ui/Stepper.tsx +74 -0
- package/src/components/ui/Table.tsx +55 -0
- package/src/components/ui/Tabs.tsx +53 -0
- package/src/components/ui/Terminal.tsx +54 -0
- package/src/components/ui/ThemeDomSync.tsx +17 -0
- package/src/components/ui/ThemeManager.tsx +18 -0
- package/src/components/ui/ThemeSwitcher.tsx +46 -0
- package/src/components/ui/Timeline.tsx +60 -0
- package/src/components/ui/Toast.tsx +70 -0
- package/src/components/ui/Typography.tsx +129 -0
- package/src/components/ui/first-slice.test.tsx +98 -0
- package/src/components/ui/index.ts +151 -0
- package/src/components/ui/interaction-primitives.test.tsx +77 -0
- package/src/components/ui/kbd-icons.test.tsx +90 -0
- package/src/components/ui/product-patterns.test.tsx +77 -0
- package/src/styles/themes.css +528 -0
- package/tailwind-preset.ts +7 -0
|
@@ -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,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
4
|
+
import { useRef, type ReactNode } from "react";
|
|
5
|
+
import { IconClose } from "@/components/icons";
|
|
6
|
+
import { cn } from "@/lib/cn";
|
|
7
|
+
|
|
8
|
+
export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
|
9
|
+
|
|
10
|
+
export interface ModalProps {
|
|
11
|
+
open?: boolean;
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
footer?: ReactNode;
|
|
17
|
+
size?: ModalSize;
|
|
18
|
+
className?: string;
|
|
19
|
+
showCloseButton?: boolean;
|
|
20
|
+
closeOnOutsideClick?: boolean;
|
|
21
|
+
closeOnEscape?: boolean;
|
|
22
|
+
"aria-label"?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sizeMap: Record<ModalSize, string> = {
|
|
26
|
+
sm: "max-w-sm",
|
|
27
|
+
md: "max-w-lg",
|
|
28
|
+
lg: "max-w-2xl",
|
|
29
|
+
xl: "max-w-4xl",
|
|
30
|
+
full: "max-w-none",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function Modal({
|
|
34
|
+
open = false,
|
|
35
|
+
onClose,
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
children,
|
|
39
|
+
footer,
|
|
40
|
+
size = "md",
|
|
41
|
+
className,
|
|
42
|
+
showCloseButton = true,
|
|
43
|
+
closeOnOutsideClick = true,
|
|
44
|
+
closeOnEscape = true,
|
|
45
|
+
"aria-label": ariaLabel,
|
|
46
|
+
}: ModalProps) {
|
|
47
|
+
const canDismiss = Boolean(onClose);
|
|
48
|
+
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
|
49
|
+
|
|
50
|
+
return (
|
|
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>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { IconChevronLeft, IconChevronRight } from "@/components/icons";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface BreadcrumbItem {
|
|
5
|
+
label: string;
|
|
6
|
+
href?: string;
|
|
7
|
+
current?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BreadcrumbsProps {
|
|
11
|
+
items: BreadcrumbItem[];
|
|
12
|
+
label?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Breadcrumbs({ items, label = "Breadcrumb", className }: BreadcrumbsProps) {
|
|
17
|
+
return (
|
|
18
|
+
<nav aria-label={label} className={cn("ph-breadcrumbs", className)}>
|
|
19
|
+
{items.map((item, index) => (
|
|
20
|
+
<span key={`${item.label}-${index}`} className="contents">
|
|
21
|
+
{index > 0 ? <span className="ph-breadcrumb-sep" aria-hidden="true">/</span> : null}
|
|
22
|
+
{item.current || !item.href ? (
|
|
23
|
+
<span className="ph-breadcrumb-current" aria-current={item.current ? "page" : undefined}>
|
|
24
|
+
{item.label}
|
|
25
|
+
</span>
|
|
26
|
+
) : (
|
|
27
|
+
<a href={item.href} className="ph-breadcrumb-link">
|
|
28
|
+
{item.label}
|
|
29
|
+
</a>
|
|
30
|
+
)}
|
|
31
|
+
</span>
|
|
32
|
+
))}
|
|
33
|
+
</nav>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PaginationProps {
|
|
38
|
+
page: number;
|
|
39
|
+
totalPages: number;
|
|
40
|
+
onPageChange?: (page: number) => void;
|
|
41
|
+
label?: string;
|
|
42
|
+
compact?: boolean;
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Pagination({
|
|
47
|
+
page,
|
|
48
|
+
totalPages,
|
|
49
|
+
onPageChange,
|
|
50
|
+
label = "Pagination",
|
|
51
|
+
compact = false,
|
|
52
|
+
className,
|
|
53
|
+
}: PaginationProps) {
|
|
54
|
+
const pages = compact ? [Math.max(1, page - 1), page, Math.min(totalPages, page + 1)] : [1, 2, 3, totalPages];
|
|
55
|
+
const uniquePages = Array.from(new Set(pages)).filter((n) => n >= 1 && n <= totalPages);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<nav aria-label={label} className={cn("ph-pagination", className)}>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className="ph-pagination-btn"
|
|
62
|
+
disabled={page <= 1}
|
|
63
|
+
aria-label="Previous page"
|
|
64
|
+
onClick={() => onPageChange?.(page - 1)}
|
|
65
|
+
>
|
|
66
|
+
<IconChevronLeft className="mx-auto h-4 w-4" aria-hidden />
|
|
67
|
+
</button>
|
|
68
|
+
{uniquePages.map((pageNumber, index) => (
|
|
69
|
+
<span key={pageNumber} className="contents">
|
|
70
|
+
{!compact && index === uniquePages.length - 1 && pageNumber > 4 ? (
|
|
71
|
+
<span className="ph-pagination-ellipsis" aria-hidden="true">...</span>
|
|
72
|
+
) : null}
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className={cn("ph-pagination-btn", pageNumber === page && "ph-pagination-btn-active")}
|
|
76
|
+
aria-current={pageNumber === page ? "page" : undefined}
|
|
77
|
+
onClick={() => onPageChange?.(pageNumber)}
|
|
78
|
+
>
|
|
79
|
+
{pageNumber}
|
|
80
|
+
</button>
|
|
81
|
+
</span>
|
|
82
|
+
))}
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
className="ph-pagination-btn"
|
|
86
|
+
disabled={page >= totalPages}
|
|
87
|
+
aria-label="Next page"
|
|
88
|
+
onClick={() => onPageChange?.(page + 1)}
|
|
89
|
+
>
|
|
90
|
+
<IconChevronRight className="mx-auto h-4 w-4" aria-hidden />
|
|
91
|
+
</button>
|
|
92
|
+
</nav>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface PanelProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
5
|
+
title?: ReactNode;
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Panel({ title, description, children, className, ...props }: PanelProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div className={cn("ph-panel", className)} {...props}>
|
|
13
|
+
{title && <h3 className="text-sm font-semibold uppercase tracking-wider text-ph-mutedtext">{title}</h3>}
|
|
14
|
+
{description && <p className="text-sm text-ph-subtle">{description}</p>}
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface ProgressProps extends ComponentPropsWithoutRef<"div"> {
|
|
5
|
+
/** Current value (0-100) */
|
|
6
|
+
value: number;
|
|
7
|
+
/** Maximum value (default 100) */
|
|
8
|
+
max?: number;
|
|
9
|
+
/** Color class for the bar */
|
|
10
|
+
color?: string;
|
|
11
|
+
/** Optional label shown above */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Show percentage text */
|
|
14
|
+
showPercentage?: boolean;
|
|
15
|
+
/** Size variant */
|
|
16
|
+
size?: "sm" | "md" | "lg";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sizeMap = {
|
|
20
|
+
sm: "h-1",
|
|
21
|
+
md: "h-2",
|
|
22
|
+
lg: "h-3",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function Progress({
|
|
26
|
+
value,
|
|
27
|
+
max = 100,
|
|
28
|
+
color = "bg-ph-brand",
|
|
29
|
+
label,
|
|
30
|
+
showPercentage = false,
|
|
31
|
+
size = "md",
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: ProgressProps) {
|
|
35
|
+
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn("w-full", className)} {...props}>
|
|
39
|
+
{(label || showPercentage) && (
|
|
40
|
+
<div className="mb-1.5 flex justify-between text-sm text-ph-subtle">
|
|
41
|
+
{label && <span>{label}</span>}
|
|
42
|
+
{showPercentage && (
|
|
43
|
+
<span className="tabular-nums">{Math.round(percentage)}%</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
<div className={cn("ph-progress", sizeMap[size])}>
|
|
48
|
+
<div
|
|
49
|
+
className={cn("ph-progress-bar", color)}
|
|
50
|
+
style={{ width: `${percentage}%` }}
|
|
51
|
+
role="progressbar"
|
|
52
|
+
aria-valuenow={value}
|
|
53
|
+
aria-valuemin={0}
|
|
54
|
+
aria-valuemax={max}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { IconStar } from "@/components/icons";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
|
|
5
|
+
export interface RatingProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
|
|
6
|
+
label?: string;
|
|
7
|
+
value?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
onChange?: (value: number) => void;
|
|
11
|
+
wrapperClassName?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Rating({
|
|
15
|
+
label,
|
|
16
|
+
value = 0,
|
|
17
|
+
max = 5,
|
|
18
|
+
readOnly = true,
|
|
19
|
+
onChange,
|
|
20
|
+
className,
|
|
21
|
+
wrapperClassName,
|
|
22
|
+
...props
|
|
23
|
+
}: RatingProps) {
|
|
24
|
+
const stars = Array.from({ length: max }, (_, index) => index + 1);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn("ph-field", wrapperClassName)}>
|
|
28
|
+
{label && <span className="ph-label">{label}</span>}
|
|
29
|
+
<div
|
|
30
|
+
className={cn("ph-rating flex", className)}
|
|
31
|
+
role={readOnly ? "img" : "radiogroup"}
|
|
32
|
+
aria-label={label ?? `Rating: ${value} out of ${max}`}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
{stars.map((star) => {
|
|
36
|
+
const active = star <= value;
|
|
37
|
+
|
|
38
|
+
if (readOnly) {
|
|
39
|
+
return (
|
|
40
|
+
<IconStar
|
|
41
|
+
key={star}
|
|
42
|
+
className={cn("h-6 w-6", active ? "text-amber-500" : "text-ph-border")}
|
|
43
|
+
aria-hidden
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<button
|
|
50
|
+
key={star}
|
|
51
|
+
type="button"
|
|
52
|
+
className="rounded p-0.5 transition hover:scale-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ph-brand"
|
|
53
|
+
role="radio"
|
|
54
|
+
aria-checked={active}
|
|
55
|
+
aria-label={`${star} out of ${max}`}
|
|
56
|
+
onClick={() => onChange?.(star)}
|
|
57
|
+
>
|
|
58
|
+
<IconStar className={cn("h-6 w-6", active ? "text-amber-500" : "text-ph-border")} />
|
|
59
|
+
</button>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { IconSearch } from "@/components/icons";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
import { Button } from "@/components/ui/Button";
|
|
6
|
+
|
|
7
|
+
export interface SearchInputProps extends Omit<ComponentPropsWithoutRef<"input">, "type"> {
|
|
8
|
+
/** Associated label text — always rendered (visually hidden when `hideLabel`) */
|
|
9
|
+
label?: string;
|
|
10
|
+
hideLabel?: boolean;
|
|
11
|
+
/** Optional shortcut hint shown inside the field (e.g. "⌘K") */
|
|
12
|
+
shortcut?: string;
|
|
13
|
+
density?: "default" | "compact";
|
|
14
|
+
wrapperClassName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Labelled search field — use in nav bars and toolbars. */
|
|
18
|
+
export function SearchInput({
|
|
19
|
+
id: idProp,
|
|
20
|
+
label = "Search",
|
|
21
|
+
hideLabel = false,
|
|
22
|
+
shortcut,
|
|
23
|
+
density = "default",
|
|
24
|
+
className,
|
|
25
|
+
wrapperClassName,
|
|
26
|
+
placeholder,
|
|
27
|
+
...props
|
|
28
|
+
}: SearchInputProps) {
|
|
29
|
+
const autoId = useId();
|
|
30
|
+
const id = idProp ?? autoId;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className={cn("relative", wrapperClassName)}>
|
|
34
|
+
<label htmlFor={id} className={hideLabel ? "sr-only" : "ph-label mb-1.5 block"}>
|
|
35
|
+
{label}
|
|
36
|
+
</label>
|
|
37
|
+
<div className="relative">
|
|
38
|
+
{shortcut != null ? (
|
|
39
|
+
<span
|
|
40
|
+
className="ph-shortcut pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-xs"
|
|
41
|
+
aria-hidden
|
|
42
|
+
>
|
|
43
|
+
{shortcut}
|
|
44
|
+
</span>
|
|
45
|
+
) : null}
|
|
46
|
+
<input
|
|
47
|
+
id={id}
|
|
48
|
+
type="search"
|
|
49
|
+
placeholder={placeholder}
|
|
50
|
+
aria-label={hideLabel ? label : undefined}
|
|
51
|
+
className={cn(
|
|
52
|
+
"ph-input w-full",
|
|
53
|
+
density === "compact" && "max-w-xs !min-h-[2rem] py-1.5 text-sm",
|
|
54
|
+
shortcut != null && "pl-[3.25rem]",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SearchGroupProps extends Omit<ComponentPropsWithoutRef<"input">, "type"> {
|
|
65
|
+
label?: string;
|
|
66
|
+
submitLabel?: string;
|
|
67
|
+
wrapperClassName?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Input + primary search — one control shell, 1px vertical seam, matched height to `ph-input`. */
|
|
71
|
+
export function SearchGroup({
|
|
72
|
+
id: idProp,
|
|
73
|
+
label = "Search records",
|
|
74
|
+
submitLabel = "Search",
|
|
75
|
+
placeholder = "Persons, actions, flags…",
|
|
76
|
+
wrapperClassName,
|
|
77
|
+
className,
|
|
78
|
+
...props
|
|
79
|
+
}: SearchGroupProps) {
|
|
80
|
+
const autoId = useId();
|
|
81
|
+
const id = idProp ?? autoId;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn("ph-field", wrapperClassName)}>
|
|
85
|
+
<label htmlFor={id} className="ph-label">
|
|
86
|
+
{label}
|
|
87
|
+
</label>
|
|
88
|
+
<div className="ph-search-group flex w-full max-w-lg min-h-[2.3125rem] items-stretch divide-x divide-ph-border">
|
|
89
|
+
<input
|
|
90
|
+
id={id}
|
|
91
|
+
type="search"
|
|
92
|
+
placeholder={placeholder}
|
|
93
|
+
className={cn("ph-input ph-input-group-field min-w-0 flex-1", className)}
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
<Button
|
|
97
|
+
type="submit"
|
|
98
|
+
variant="primary"
|
|
99
|
+
icon={<IconSearch className="h-4 w-4" aria-hidden />}
|
|
100
|
+
className="ph-search-group__submit"
|
|
101
|
+
aria-label={submitLabel}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
|
|
5
|
+
interface SegmentedControlOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SegmentedControlProps {
|
|
12
|
+
options: SegmentedControlOption[];
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (value: string) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
"inline-flex rounded-lg bg-ph-muted p-1",
|
|
23
|
+
className
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
{options.map((option) => (
|
|
27
|
+
<button
|
|
28
|
+
key={option.value}
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={() => onChange(option.value)}
|
|
31
|
+
className={cn(
|
|
32
|
+
"relative flex items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all",
|
|
33
|
+
value === option.value
|
|
34
|
+
? "bg-ph-surface text-ph-ink shadow-ph"
|
|
35
|
+
: "text-ph-subtle hover:text-ph-ink"
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{option.icon && <span className="h-4 w-4">{option.icon}</span>}
|
|
39
|
+
{option.label}
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import { IconTuning } from "@/components/icons";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
|
|
5
|
+
export interface SettingsNavItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
href: string;
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
active?: boolean;
|
|
11
|
+
badge?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SettingsNavGroup {
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
items: readonly SettingsNavItem[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SettingsNavProps extends ComponentPropsWithoutRef<"div"> {
|
|
20
|
+
groups: readonly SettingsNavGroup[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SettingsLayoutProps extends Omit<ComponentPropsWithoutRef<"section">, "title"> {
|
|
24
|
+
title: ReactNode;
|
|
25
|
+
description?: ReactNode;
|
|
26
|
+
actions?: ReactNode;
|
|
27
|
+
navigation: ReactNode;
|
|
28
|
+
navigationLabel?: string;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div className={cn("ph-settings-nav", className)} {...props}>
|
|
35
|
+
{groups.map((group, index) => (
|
|
36
|
+
<div className="ph-settings-nav__group" key={index}>
|
|
37
|
+
<h3>{group.label}</h3>
|
|
38
|
+
<div className="ph-settings-nav__items">
|
|
39
|
+
{group.items.map((item) => (
|
|
40
|
+
<a
|
|
41
|
+
key={item.id}
|
|
42
|
+
href={item.href}
|
|
43
|
+
className={cn("ph-settings-nav__item", item.active && "ph-settings-nav__item--active")}
|
|
44
|
+
aria-current={item.active ? "page" : undefined}
|
|
45
|
+
>
|
|
46
|
+
{item.icon ? <span className="ph-settings-nav__icon">{item.icon}</span> : null}
|
|
47
|
+
<span>{item.label}</span>
|
|
48
|
+
{item.badge ? <span className="ph-settings-nav__badge">{item.badge}</span> : null}
|
|
49
|
+
</a>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SettingsLayout({
|
|
59
|
+
title,
|
|
60
|
+
description,
|
|
61
|
+
actions,
|
|
62
|
+
navigation,
|
|
63
|
+
navigationLabel = "Settings",
|
|
64
|
+
children,
|
|
65
|
+
className,
|
|
66
|
+
...props
|
|
67
|
+
}: SettingsLayoutProps) {
|
|
68
|
+
return (
|
|
69
|
+
<section className={cn("ph-settings-layout", className)} {...props}>
|
|
70
|
+
<header className="ph-settings-layout__header">
|
|
71
|
+
<div>
|
|
72
|
+
<h2>{title}</h2>
|
|
73
|
+
{description ? <p>{description}</p> : null}
|
|
74
|
+
</div>
|
|
75
|
+
{actions ? <div className="ph-settings-layout__actions">{actions}</div> : null}
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<details className="ph-settings-layout__mobile-nav">
|
|
79
|
+
<summary>
|
|
80
|
+
<IconTuning className="h-4 w-4" aria-hidden />
|
|
81
|
+
Settings menu
|
|
82
|
+
</summary>
|
|
83
|
+
<nav aria-label={navigationLabel}>{navigation}</nav>
|
|
84
|
+
</details>
|
|
85
|
+
|
|
86
|
+
<div className="ph-settings-layout__grid">
|
|
87
|
+
<aside className="ph-settings-layout__sidebar">
|
|
88
|
+
<nav aria-label={navigationLabel}>{navigation}</nav>
|
|
89
|
+
</aside>
|
|
90
|
+
<div className="ph-settings-layout__content">{children}</div>
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|