@olympusoss/canvas 2.6.19
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/package.json +179 -0
- package/src/components/atoms/README.md +11 -0
- package/src/components/atoms/aspect-ratio.tsx +32 -0
- package/src/components/atoms/avatar.tsx +98 -0
- package/src/components/atoms/badge.tsx +44 -0
- package/src/components/atoms/brand-mark.tsx +74 -0
- package/src/components/atoms/button.tsx +104 -0
- package/src/components/atoms/checkbox.tsx +63 -0
- package/src/components/atoms/flex-box.tsx +105 -0
- package/src/components/atoms/icon.tsx +34 -0
- package/src/components/atoms/input.tsx +91 -0
- package/src/components/atoms/label.tsx +41 -0
- package/src/components/atoms/logo.tsx +89 -0
- package/src/components/atoms/progress.tsx +55 -0
- package/src/components/atoms/radio-group.tsx +122 -0
- package/src/components/atoms/scroll-area.tsx +106 -0
- package/src/components/atoms/section.tsx +48 -0
- package/src/components/atoms/separator.tsx +45 -0
- package/src/components/atoms/skeleton.tsx +17 -0
- package/src/components/atoms/slider.tsx +93 -0
- package/src/components/atoms/switch.tsx +60 -0
- package/src/components/atoms/textarea.tsx +78 -0
- package/src/components/atoms/toggle.tsx +80 -0
- package/src/components/charts/activity-heatmap.tsx +96 -0
- package/src/components/charts/axes.tsx +21 -0
- package/src/components/charts/chart-container.tsx +195 -0
- package/src/components/charts/chart-legend.tsx +67 -0
- package/src/components/charts/chart-tooltip.tsx +161 -0
- package/src/components/charts/chart-types.tsx +49 -0
- package/src/components/charts/containers.tsx +11 -0
- package/src/components/charts/data.tsx +16 -0
- package/src/components/charts/details.tsx +25 -0
- package/src/components/charts/gauge.tsx +106 -0
- package/src/components/charts/grids.tsx +8 -0
- package/src/components/charts/index.ts +62 -0
- package/src/components/charts/labeled-bar-list.tsx +85 -0
- package/src/components/charts/references.tsx +8 -0
- package/src/components/charts/service-health-list.tsx +73 -0
- package/src/components/charts/sparkline.tsx +52 -0
- package/src/components/charts/stacked-bar.tsx +104 -0
- package/src/components/charts/text.tsx +10 -0
- package/src/components/charts/world-heat-map-inner.tsx +317 -0
- package/src/components/charts/world-heat-map.tsx +184 -0
- package/src/components/molecules/README.md +12 -0
- package/src/components/molecules/action-bar.tsx +73 -0
- package/src/components/molecules/activity-item.tsx +74 -0
- package/src/components/molecules/alert.tsx +80 -0
- package/src/components/molecules/animated-background.tsx +92 -0
- package/src/components/molecules/brand-lockup.tsx +48 -0
- package/src/components/molecules/breadcrumb.tsx +161 -0
- package/src/components/molecules/button-group.tsx +104 -0
- package/src/components/molecules/calendar.tsx +216 -0
- package/src/components/molecules/card.tsx +101 -0
- package/src/components/molecules/code-block.tsx +48 -0
- package/src/components/molecules/empty-state.tsx +55 -0
- package/src/components/molecules/error-state.tsx +42 -0
- package/src/components/molecules/field-display.tsx +35 -0
- package/src/components/molecules/input-otp.tsx +74 -0
- package/src/components/molecules/loading-state.tsx +36 -0
- package/src/components/molecules/notification-item.tsx +67 -0
- package/src/components/molecules/notification-list.tsx +45 -0
- package/src/components/molecules/number-badge.tsx +53 -0
- package/src/components/molecules/page-header.tsx +88 -0
- package/src/components/molecules/page-tabs.tsx +94 -0
- package/src/components/molecules/pagination.tsx +150 -0
- package/src/components/molecules/phone-input.tsx +200 -0
- package/src/components/molecules/search-bar.tsx +64 -0
- package/src/components/molecules/secret-field.tsx +158 -0
- package/src/components/molecules/section-card.tsx +91 -0
- package/src/components/molecules/stat-card.tsx +96 -0
- package/src/components/molecules/status-badge.tsx +42 -0
- package/src/components/molecules/stepper.tsx +96 -0
- package/src/components/molecules/table.tsx +157 -0
- package/src/components/molecules/toggle-group.tsx +145 -0
- package/src/components/molecules/tooltip.tsx +150 -0
- package/src/components/molecules/user-avatar-chip.tsx +71 -0
- package/src/components/organisms/README.md +14 -0
- package/src/components/organisms/accordion.tsx +149 -0
- package/src/components/organisms/alert-dialog.tsx +269 -0
- package/src/components/organisms/carousel.tsx +244 -0
- package/src/components/organisms/collapsible.tsx +69 -0
- package/src/components/organisms/command.tsx +143 -0
- package/src/components/organisms/context-menu.tsx +333 -0
- package/src/components/organisms/dashboard-grid.tsx +360 -0
- package/src/components/organisms/data-table.tsx +330 -0
- package/src/components/organisms/dialog.tsx +304 -0
- package/src/components/organisms/drawer.tsx +100 -0
- package/src/components/organisms/dropdown-menu.tsx +434 -0
- package/src/components/organisms/editors/code-editor.tsx +144 -0
- package/src/components/organisms/editors/index.ts +4 -0
- package/src/components/organisms/editors/markdown-editor.tsx +153 -0
- package/src/components/organisms/editors/markdown-renderer.ts +27 -0
- package/src/components/organisms/editors/prose-canvas-classes.ts +45 -0
- package/src/components/organisms/editors/rich-text-editor.tsx +126 -0
- package/src/components/organisms/editors/toolbar/md-toolbar.tsx +129 -0
- package/src/components/organisms/editors/toolbar/rte-toolbar.tsx +211 -0
- package/src/components/organisms/editors/toolbar/toolbar-shell.tsx +45 -0
- package/src/components/organisms/editors/use-codemirror-theme.ts +61 -0
- package/src/components/organisms/error-boundary.tsx +61 -0
- package/src/components/organisms/form.tsx +174 -0
- package/src/components/organisms/hover-card.tsx +114 -0
- package/src/components/organisms/menubar.tsx +491 -0
- package/src/components/organisms/navbar.tsx +101 -0
- package/src/components/organisms/navigation-menu.tsx +234 -0
- package/src/components/organisms/popover.tsx +144 -0
- package/src/components/organisms/resizable.tsx +39 -0
- package/src/components/organisms/schema-form.tsx +232 -0
- package/src/components/organisms/select.tsx +303 -0
- package/src/components/organisms/sheet.tsx +256 -0
- package/src/components/organisms/sidebar.tsx +1037 -0
- package/src/components/organisms/sonner.tsx +96 -0
- package/src/components/organisms/tabs.tsx +132 -0
- package/src/components/organisms/theme-provider.tsx +101 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/index.ts +547 -0
- package/src/lib/portal-container.tsx +35 -0
- package/src/lib/utils.ts +6 -0
- package/src/native.ts +23 -0
- package/src/tokens/colors.ts +91 -0
- package/src/tokens/index.ts +3 -0
- package/src/tokens/spacing.ts +55 -0
- package/src/tokens/typography.ts +27 -0
- package/styles/canvas.css +55 -0
- package/styles/dashboard-grid.css +47 -0
- package/styles/leaflet.css +13 -0
- package/styles/tokens.css +234 -0
- package/tailwind.config.ts +70 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface NotificationListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
6
|
+
/** Header title — defaults to "Notifications". */
|
|
7
|
+
title?: React.ReactNode;
|
|
8
|
+
/** Optional count chip rendered to the right of the title (e.g. "3 new"). */
|
|
9
|
+
count?: React.ReactNode;
|
|
10
|
+
/** Footer slot — typically a single full-width "View all" link/button. */
|
|
11
|
+
footer?: React.ReactNode;
|
|
12
|
+
/** Notification rows — typically `<NotificationItem>` instances. */
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const NotificationList = React.forwardRef<HTMLDivElement, NotificationListProps>(
|
|
17
|
+
({ title = "Notifications", count, footer, children, className, ...props }, ref) => {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
ref={ref}
|
|
21
|
+
className={cn(
|
|
22
|
+
"w-80 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...props}
|
|
26
|
+
>
|
|
27
|
+
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
|
|
28
|
+
<span className="text-[13px] font-semibold">{title}</span>
|
|
29
|
+
{count != null && (
|
|
30
|
+
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
31
|
+
{count}
|
|
32
|
+
</span>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
<div className="max-h-96 divide-y divide-border overflow-y-auto">{children}</div>
|
|
36
|
+
{footer && (
|
|
37
|
+
<div className="border-t border-border bg-muted/30 px-3 py-2 text-center text-[12.5px] font-medium">
|
|
38
|
+
{footer}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
NotificationList.displayName = "NotificationList";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface NumberBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
6
|
+
/** Numeric count. When omitted (or `dot` is true), renders a tone-tinted dot. */
|
|
7
|
+
count?: number;
|
|
8
|
+
/** Cap displayed count — anything above renders as `${max}+`. Default 99. */
|
|
9
|
+
max?: number;
|
|
10
|
+
/** Color tone. */
|
|
11
|
+
tone?: "destructive" | "default" | "muted";
|
|
12
|
+
/** Render as a dot regardless of count. */
|
|
13
|
+
dot?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TONE: Record<NonNullable<NumberBadgeProps["tone"]>, string> = {
|
|
17
|
+
destructive: "bg-destructive text-destructive-foreground",
|
|
18
|
+
default: "bg-primary text-primary-foreground",
|
|
19
|
+
muted: "bg-muted text-muted-foreground",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Small overlay badge for counts on icon buttons (notification bell, inbox,
|
|
24
|
+
* etc.). Position it absolutely against a `relative` parent — typically by
|
|
25
|
+
* pairing with `absolute -right-1 -top-1` or similar utility classes via
|
|
26
|
+
* `className`. Defaults to top-right placement.
|
|
27
|
+
*/
|
|
28
|
+
export const NumberBadge = React.forwardRef<HTMLSpanElement, NumberBadgeProps>(
|
|
29
|
+
({ count, max = 99, tone = "destructive", dot, className, ...props }, ref) => {
|
|
30
|
+
const isDot = dot || count == null;
|
|
31
|
+
const display = !isDot && count != null && count > max ? `${max}+` : `${count ?? ""}`;
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
ref={ref}
|
|
35
|
+
role="status"
|
|
36
|
+
aria-label={isDot ? "New" : `${count} new`}
|
|
37
|
+
className={cn(
|
|
38
|
+
"pointer-events-none absolute -right-1 -top-1 inline-flex items-center justify-center font-mono font-medium tabular-nums",
|
|
39
|
+
isDot
|
|
40
|
+
? "h-1.5 w-1.5 rounded-full"
|
|
41
|
+
: "min-w-[1.125rem] rounded-full px-1 text-[10px] leading-none",
|
|
42
|
+
!isDot && "h-[1.125rem]",
|
|
43
|
+
TONE[tone],
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{!isDot && display}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
NumberBadge.displayName = "NumberBadge";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
import { Icon } from "../atoms/icon";
|
|
5
|
+
|
|
6
|
+
export interface PageHeaderBreadcrumb {
|
|
7
|
+
label: string;
|
|
8
|
+
href?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PageHeaderProps {
|
|
12
|
+
title: string | React.ReactNode;
|
|
13
|
+
subtitle?: string | React.ReactNode;
|
|
14
|
+
icon?: React.ReactNode;
|
|
15
|
+
actions?: React.ReactNode;
|
|
16
|
+
breadcrumbs?: PageHeaderBreadcrumb[];
|
|
17
|
+
/**
|
|
18
|
+
* Optional link wrapper. Pass Next.js `Link` or React Router `Link` to make
|
|
19
|
+
* breadcrumb links client-side routable. Defaults to a plain `<a>`.
|
|
20
|
+
*/
|
|
21
|
+
linkComponent?: React.ComponentType<{
|
|
22
|
+
href: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}>;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function PageHeader({
|
|
30
|
+
title,
|
|
31
|
+
subtitle,
|
|
32
|
+
icon,
|
|
33
|
+
actions,
|
|
34
|
+
breadcrumbs,
|
|
35
|
+
linkComponent: LinkComp,
|
|
36
|
+
className,
|
|
37
|
+
}: PageHeaderProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn("mb-6 flex items-start justify-between", className)}>
|
|
40
|
+
<div className="space-y-1">
|
|
41
|
+
{breadcrumbs && breadcrumbs.length > 0 && (
|
|
42
|
+
<div className="mb-2 flex items-center gap-1 text-sm text-muted-foreground">
|
|
43
|
+
{breadcrumbs.map((crumb, i) => (
|
|
44
|
+
<span key={crumb.label} className="flex items-center gap-1">
|
|
45
|
+
{crumb.href ? (
|
|
46
|
+
LinkComp ? (
|
|
47
|
+
<LinkComp href={crumb.href} className="hover:text-foreground transition-colors">
|
|
48
|
+
{crumb.label}
|
|
49
|
+
</LinkComp>
|
|
50
|
+
) : (
|
|
51
|
+
<a href={crumb.href} className="hover:text-foreground transition-colors">
|
|
52
|
+
{crumb.label}
|
|
53
|
+
</a>
|
|
54
|
+
)
|
|
55
|
+
) : (
|
|
56
|
+
<span className="text-foreground">{crumb.label}</span>
|
|
57
|
+
)}
|
|
58
|
+
{i < breadcrumbs.length - 1 && <Icon name="ChevronRight" className="h-3 w-3" />}
|
|
59
|
+
</span>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
<div className="flex items-center gap-3">
|
|
64
|
+
{icon}
|
|
65
|
+
{typeof title === "string" ? (
|
|
66
|
+
<h1 className="text-[22px] font-semibold tracking-[-0.02em] text-foreground">
|
|
67
|
+
{title}
|
|
68
|
+
</h1>
|
|
69
|
+
) : (
|
|
70
|
+
title
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
{subtitle && (
|
|
74
|
+
<div>
|
|
75
|
+
{typeof subtitle === "string" ? (
|
|
76
|
+
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
|
77
|
+
) : (
|
|
78
|
+
subtitle
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
PageHeader.displayName = "PageHeader";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface PageTabsItem {
|
|
8
|
+
label: string;
|
|
9
|
+
value: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
badge?: number | string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PageTabsProps {
|
|
15
|
+
tabs: PageTabsItem[];
|
|
16
|
+
value: string;
|
|
17
|
+
onChange: (value: string) => void;
|
|
18
|
+
variant?: "default" | "pills" | "underline";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderBadge(badge: number | string | undefined, active: boolean): React.ReactNode {
|
|
22
|
+
if (badge === undefined) return null;
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className={cn(
|
|
26
|
+
"ml-1 rounded-full px-1.5 py-0.5 text-xs font-medium",
|
|
27
|
+
active ? "bg-primary/10 text-primary" : "bg-muted-foreground/10 text-muted-foreground",
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
{badge}
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PageTabs({ tabs, value, onChange, variant = "default" }: PageTabsProps) {
|
|
36
|
+
if (variant === "underline") {
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex gap-4 border-b border-border">
|
|
39
|
+
{tabs.map((tab) => {
|
|
40
|
+
const active = value === tab.value;
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
key={tab.value}
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => onChange(tab.value)}
|
|
46
|
+
className={cn(
|
|
47
|
+
"inline-flex items-center gap-1.5 border-b-2 px-1 pb-3 text-sm font-medium transition-colors",
|
|
48
|
+
active
|
|
49
|
+
? "border-primary text-primary"
|
|
50
|
+
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{tab.icon && <span className="h-4 w-4">{tab.icon}</span>}
|
|
54
|
+
{tab.label}
|
|
55
|
+
{renderBadge(tab.badge, active)}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isPills = variant === "pills";
|
|
64
|
+
return (
|
|
65
|
+
<div className={cn("flex flex-wrap gap-1", isPills && "rounded-lg bg-muted p-1")}>
|
|
66
|
+
{tabs.map((tab) => {
|
|
67
|
+
const active = value === tab.value;
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
key={tab.value}
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => onChange(tab.value)}
|
|
73
|
+
className={cn(
|
|
74
|
+
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
|
75
|
+
isPills
|
|
76
|
+
? active
|
|
77
|
+
? "bg-background text-foreground shadow-sm"
|
|
78
|
+
: "text-muted-foreground hover:text-foreground"
|
|
79
|
+
: active
|
|
80
|
+
? "bg-accent text-accent-foreground"
|
|
81
|
+
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{tab.icon && <span className="h-4 w-4">{tab.icon}</span>}
|
|
85
|
+
{tab.label}
|
|
86
|
+
{renderBadge(tab.badge, active)}
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
PageTabs.displayName = "PageTabs";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
import { type ButtonProps, buttonVariants } from "../atoms/button";
|
|
6
|
+
|
|
7
|
+
export interface PaginationProps extends React.ComponentProps<"nav"> {
|
|
8
|
+
/** A `<PaginationContent>` with `<PaginationItem>`s. */
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
/** Tailwind / CSS classes merged onto the `<nav>` via `cn()`. */
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Pagination = ({ className, ...props }: PaginationProps) => (
|
|
15
|
+
<nav
|
|
16
|
+
role="navigation"
|
|
17
|
+
aria-label="pagination"
|
|
18
|
+
className={cn("mx-auto flex w-full justify-center", className)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
Pagination.displayName = "Pagination";
|
|
23
|
+
|
|
24
|
+
export interface PaginationContentProps extends React.ComponentProps<"ul"> {
|
|
25
|
+
/** A flat list of `<PaginationItem>`s. */
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
/** Tailwind / CSS classes merged onto the `<ul>` via `cn()`. */
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PaginationContent = React.forwardRef<HTMLUListElement, PaginationContentProps>(
|
|
32
|
+
({ className, ...props }, ref) => (
|
|
33
|
+
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
PaginationContent.displayName = "PaginationContent";
|
|
37
|
+
|
|
38
|
+
export interface PaginationItemProps extends React.ComponentProps<"li"> {
|
|
39
|
+
/** Typically a `<PaginationLink>`, `<PaginationPrevious>`, `<PaginationNext>`, or `<PaginationEllipsis>`. */
|
|
40
|
+
children?: React.ReactNode;
|
|
41
|
+
/** Tailwind / CSS classes merged onto the `<li>` via `cn()`. */
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PaginationItem = React.forwardRef<HTMLLIElement, PaginationItemProps>(
|
|
46
|
+
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
|
|
47
|
+
);
|
|
48
|
+
PaginationItem.displayName = "PaginationItem";
|
|
49
|
+
|
|
50
|
+
export interface PaginationLinkProps extends React.ComponentProps<"a"> {
|
|
51
|
+
/**
|
|
52
|
+
* Mark this link as the current page. Adds `aria-current="page"` and
|
|
53
|
+
* applies the outline button variant.
|
|
54
|
+
* @default false
|
|
55
|
+
*/
|
|
56
|
+
isActive?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Button-style size preset (inherited from `<Button>`).
|
|
59
|
+
* @default "icon"
|
|
60
|
+
*/
|
|
61
|
+
size?: ButtonProps["size"];
|
|
62
|
+
/** Page number or label. */
|
|
63
|
+
children?: React.ReactNode;
|
|
64
|
+
/** Anchor target. */
|
|
65
|
+
href?: string;
|
|
66
|
+
/** Tailwind / CSS classes merged onto the link via `cn()`. */
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
|
|
71
|
+
<a
|
|
72
|
+
aria-current={isActive ? "page" : undefined}
|
|
73
|
+
className={cn(
|
|
74
|
+
buttonVariants({
|
|
75
|
+
variant: isActive ? "outline" : "ghost",
|
|
76
|
+
size,
|
|
77
|
+
}),
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
PaginationLink.displayName = "PaginationLink";
|
|
84
|
+
|
|
85
|
+
export interface PaginationPreviousProps extends Omit<PaginationLinkProps, "size"> {
|
|
86
|
+
/** Anchor target for the previous page. */
|
|
87
|
+
href?: string;
|
|
88
|
+
/** Tailwind / CSS classes merged via `cn()`. */
|
|
89
|
+
className?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PaginationPrevious = ({ className, ...props }: PaginationPreviousProps) => (
|
|
93
|
+
<PaginationLink
|
|
94
|
+
aria-label="Go to previous page"
|
|
95
|
+
size="default"
|
|
96
|
+
className={cn("gap-1 pl-2.5", className)}
|
|
97
|
+
{...props}
|
|
98
|
+
>
|
|
99
|
+
<ChevronLeft className="h-4 w-4" />
|
|
100
|
+
<span>Previous</span>
|
|
101
|
+
</PaginationLink>
|
|
102
|
+
);
|
|
103
|
+
PaginationPrevious.displayName = "PaginationPrevious";
|
|
104
|
+
|
|
105
|
+
export interface PaginationNextProps extends Omit<PaginationLinkProps, "size"> {
|
|
106
|
+
/** Anchor target for the next page. */
|
|
107
|
+
href?: string;
|
|
108
|
+
/** Tailwind / CSS classes merged via `cn()`. */
|
|
109
|
+
className?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const PaginationNext = ({ className, ...props }: PaginationNextProps) => (
|
|
113
|
+
<PaginationLink
|
|
114
|
+
aria-label="Go to next page"
|
|
115
|
+
size="default"
|
|
116
|
+
className={cn("gap-1 pr-2.5", className)}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
<span>Next</span>
|
|
120
|
+
<ChevronRight className="h-4 w-4" />
|
|
121
|
+
</PaginationLink>
|
|
122
|
+
);
|
|
123
|
+
PaginationNext.displayName = "PaginationNext";
|
|
124
|
+
|
|
125
|
+
export interface PaginationEllipsisProps extends React.ComponentProps<"span"> {
|
|
126
|
+
/** Tailwind / CSS classes merged via `cn()`. */
|
|
127
|
+
className?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const PaginationEllipsis = ({ className, ...props }: PaginationEllipsisProps) => (
|
|
131
|
+
<span
|
|
132
|
+
aria-hidden
|
|
133
|
+
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
134
|
+
{...props}
|
|
135
|
+
>
|
|
136
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
137
|
+
<span className="sr-only">More pages</span>
|
|
138
|
+
</span>
|
|
139
|
+
);
|
|
140
|
+
PaginationEllipsis.displayName = "PaginationEllipsis";
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
Pagination,
|
|
144
|
+
PaginationContent,
|
|
145
|
+
PaginationEllipsis,
|
|
146
|
+
PaginationItem,
|
|
147
|
+
PaginationLink,
|
|
148
|
+
PaginationNext,
|
|
149
|
+
PaginationPrevious,
|
|
150
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import parsePhoneNumber, {
|
|
4
|
+
type CountryCode,
|
|
5
|
+
getCountries,
|
|
6
|
+
getCountryCallingCode,
|
|
7
|
+
isValidPhoneNumber,
|
|
8
|
+
} from "libphonenumber-js";
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
import { cn } from "../../lib/utils";
|
|
12
|
+
import { Input } from "../atoms/input";
|
|
13
|
+
import { Label } from "../atoms/label";
|
|
14
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../organisms/select";
|
|
15
|
+
|
|
16
|
+
export interface PhoneInputProps {
|
|
17
|
+
id: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
onChange: (e164Value: string | undefined) => void;
|
|
20
|
+
label?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
readonly?: boolean;
|
|
24
|
+
required?: boolean;
|
|
25
|
+
/** Default country ISO code if the value is empty. Default: "US". */
|
|
26
|
+
defaultCountry?: CountryCode;
|
|
27
|
+
/** Override the list of selectable countries (defaults to all). */
|
|
28
|
+
countryCodes?: CountryCode[];
|
|
29
|
+
/** Called with error string (or "" when valid/cleared). */
|
|
30
|
+
onValidityChange?: (error: string) => void;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CountryOption {
|
|
35
|
+
code: CountryCode;
|
|
36
|
+
name: string;
|
|
37
|
+
callingCode: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Convert an ISO 3166-1 alpha-2 country code to its flag emoji. */
|
|
41
|
+
function flagEmoji(code: string): string {
|
|
42
|
+
const A = "A".charCodeAt(0);
|
|
43
|
+
const offset = 0x1f1e6 - A;
|
|
44
|
+
const upper = code.toUpperCase();
|
|
45
|
+
/* c8 ignore next -- defensive: every CountryCode from libphonenumber is a 2-letter ISO code by construction; only reachable if a malformed string slips past TS at the call site */
|
|
46
|
+
if (upper.length !== 2) return code;
|
|
47
|
+
return String.fromCodePoint(upper.charCodeAt(0) + offset, upper.charCodeAt(1) + offset);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildCountryOptions(codes?: CountryCode[]): CountryOption[] {
|
|
51
|
+
const list = codes ?? getCountries();
|
|
52
|
+
const displayNames = new Intl.DisplayNames(["en"], { type: "region" });
|
|
53
|
+
return list
|
|
54
|
+
.map((code) => {
|
|
55
|
+
const callingCode = getCountryCallingCode(code);
|
|
56
|
+
/* c8 ignore next -- defensive: Intl.DisplayNames returns a name for every valid ISO country code in jsdom; the `|| code` fallback only fires if Intl data is stripped */
|
|
57
|
+
const name = displayNames.of(code) || code;
|
|
58
|
+
return { code, name, callingCode };
|
|
59
|
+
})
|
|
60
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function PhoneInput({
|
|
64
|
+
id,
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
label,
|
|
68
|
+
placeholder,
|
|
69
|
+
disabled,
|
|
70
|
+
readonly,
|
|
71
|
+
required,
|
|
72
|
+
defaultCountry = "US",
|
|
73
|
+
countryCodes,
|
|
74
|
+
onValidityChange,
|
|
75
|
+
className,
|
|
76
|
+
}: PhoneInputProps) {
|
|
77
|
+
const [selectedCountry, setSelectedCountry] = React.useState<CountryCode>(defaultCountry);
|
|
78
|
+
const [localValue, setLocalValue] = React.useState("");
|
|
79
|
+
const [error, setError] = React.useState("");
|
|
80
|
+
|
|
81
|
+
const countryOptions = React.useMemo(() => buildCountryOptions(countryCodes), [countryCodes]);
|
|
82
|
+
|
|
83
|
+
// Hydrate state from an external E.164 value.
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!value) {
|
|
86
|
+
setLocalValue("");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const parsed = parsePhoneNumber(value);
|
|
91
|
+
if (parsed) {
|
|
92
|
+
if (parsed.country) setSelectedCountry(parsed.country);
|
|
93
|
+
/* c8 ignore next -- defensive: a successfully-parsed PhoneNumber always exposes a nationalNumber; the `?? ""` fallback only fires if libphonenumber returns a partial object */
|
|
94
|
+
setLocalValue(parsed.nationalNumber ?? "");
|
|
95
|
+
} else {
|
|
96
|
+
setLocalValue(value);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
/* c8 ignore next -- libphonenumber never throws on arbitrary input in jsdom; only reachable if parser invariants change */
|
|
100
|
+
setLocalValue(value);
|
|
101
|
+
}
|
|
102
|
+
}, [value]);
|
|
103
|
+
|
|
104
|
+
const updateValidity = React.useCallback(
|
|
105
|
+
(e164: string) => {
|
|
106
|
+
if (!e164) {
|
|
107
|
+
const err = required ? "Phone number is required" : "";
|
|
108
|
+
setError(err);
|
|
109
|
+
onValidityChange?.(err);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const valid = isValidPhoneNumber(e164);
|
|
113
|
+
const err = valid ? "" : "Enter a valid phone number";
|
|
114
|
+
setError(err);
|
|
115
|
+
onValidityChange?.(err);
|
|
116
|
+
},
|
|
117
|
+
[required, onValidityChange],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const emit = (country: CountryCode, national: string) => {
|
|
121
|
+
if (!national.trim()) {
|
|
122
|
+
onChange(undefined);
|
|
123
|
+
updateValidity("");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const callingCode = getCountryCallingCode(country);
|
|
127
|
+
const e164 = `+${callingCode}${national.replace(/\D/g, "")}`;
|
|
128
|
+
onChange(e164);
|
|
129
|
+
updateValidity(e164);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className={cn("space-y-1.5", className)}>
|
|
134
|
+
{label && (
|
|
135
|
+
<Label htmlFor={id}>
|
|
136
|
+
{label}
|
|
137
|
+
{required && <span className="ml-0.5 text-destructive">*</span>}
|
|
138
|
+
</Label>
|
|
139
|
+
)}
|
|
140
|
+
<div className="flex gap-2">
|
|
141
|
+
<Select
|
|
142
|
+
value={selectedCountry}
|
|
143
|
+
onValueChange={(next) => {
|
|
144
|
+
const code = next as CountryCode;
|
|
145
|
+
setSelectedCountry(code);
|
|
146
|
+
emit(code, localValue);
|
|
147
|
+
}}
|
|
148
|
+
disabled={disabled || readonly}
|
|
149
|
+
>
|
|
150
|
+
<SelectTrigger
|
|
151
|
+
aria-label="Country"
|
|
152
|
+
className="w-auto shrink-0 gap-1.5 px-2.5 font-mono text-sm"
|
|
153
|
+
>
|
|
154
|
+
<SelectValue>
|
|
155
|
+
<span className="flex items-center gap-1.5">
|
|
156
|
+
<span aria-hidden className="text-base leading-none">
|
|
157
|
+
{flagEmoji(selectedCountry)}
|
|
158
|
+
</span>
|
|
159
|
+
<span>+{getCountryCallingCode(selectedCountry)}</span>
|
|
160
|
+
</span>
|
|
161
|
+
</SelectValue>
|
|
162
|
+
</SelectTrigger>
|
|
163
|
+
<SelectContent className="max-h-[320px] min-w-[260px]">
|
|
164
|
+
{countryOptions.map((opt) => (
|
|
165
|
+
<SelectItem key={opt.code} value={opt.code}>
|
|
166
|
+
<span className="flex items-center gap-2">
|
|
167
|
+
<span aria-hidden className="text-base leading-none">
|
|
168
|
+
{flagEmoji(opt.code)}
|
|
169
|
+
</span>
|
|
170
|
+
<span>{opt.name}</span>
|
|
171
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
172
|
+
+{opt.callingCode}
|
|
173
|
+
</span>
|
|
174
|
+
</span>
|
|
175
|
+
</SelectItem>
|
|
176
|
+
))}
|
|
177
|
+
</SelectContent>
|
|
178
|
+
</Select>
|
|
179
|
+
<Input
|
|
180
|
+
id={id}
|
|
181
|
+
type="tel"
|
|
182
|
+
inputMode="tel"
|
|
183
|
+
value={localValue}
|
|
184
|
+
onChange={(e) => {
|
|
185
|
+
const v = e.target.value;
|
|
186
|
+
setLocalValue(v);
|
|
187
|
+
emit(selectedCountry, v);
|
|
188
|
+
}}
|
|
189
|
+
placeholder={placeholder}
|
|
190
|
+
disabled={disabled}
|
|
191
|
+
readOnly={readonly}
|
|
192
|
+
className={cn("flex-1", error && "border-destructive focus-visible:ring-destructive")}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
PhoneInput.displayName = "PhoneInput";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Search, X } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import { Button } from "../atoms/button";
|
|
8
|
+
import { Input } from "../atoms/input";
|
|
9
|
+
|
|
10
|
+
export interface SearchBarProps
|
|
11
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
onClear?: () => void;
|
|
15
|
+
/**
|
|
16
|
+
* Optional keyboard-shortcut hint rendered as a `<kbd>` on the right of
|
|
17
|
+
* the input when empty. Hidden once the user types so the clear button
|
|
18
|
+
* has the slot. Example: `shortcut="⌘K"`.
|
|
19
|
+
*/
|
|
20
|
+
shortcut?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>(
|
|
24
|
+
({ value, onChange, onClear, shortcut, className, placeholder = "Search...", ...props }, ref) => {
|
|
25
|
+
const showShortcut = !!shortcut && value.length === 0;
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn("relative", className)}>
|
|
28
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
29
|
+
<Input
|
|
30
|
+
ref={ref}
|
|
31
|
+
value={value}
|
|
32
|
+
onChange={(e) => onChange(e.target.value)}
|
|
33
|
+
placeholder={placeholder}
|
|
34
|
+
className={cn("pl-9", showShortcut ? "pr-12" : "pr-9")}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
{value && (
|
|
38
|
+
<Button
|
|
39
|
+
variant="ghost"
|
|
40
|
+
size="icon"
|
|
41
|
+
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2"
|
|
42
|
+
onClick={() => {
|
|
43
|
+
onChange("");
|
|
44
|
+
onClear?.();
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<X className="h-3 w-3" />
|
|
48
|
+
</Button>
|
|
49
|
+
)}
|
|
50
|
+
{showShortcut && (
|
|
51
|
+
<kbd
|
|
52
|
+
aria-hidden
|
|
53
|
+
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded border border-border bg-muted/40 px-1.5 font-mono text-[10px] text-muted-foreground"
|
|
54
|
+
>
|
|
55
|
+
{shortcut}
|
|
56
|
+
</kbd>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
SearchBar.displayName = "SearchBar";
|
|
63
|
+
|
|
64
|
+
export { SearchBar };
|