@brightweblabs/ui 0.4.0 → 1.0.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/package.json +43 -7
- package/src/components/action.tsx +12 -0
- package/src/components/activity-message.tsx +1 -1
- package/src/components/alert-dialog.tsx +4 -4
- package/src/components/avatar.tsx +80 -0
- package/src/components/badge.tsx +20 -30
- package/src/components/button-variants.ts +47 -0
- package/src/components/button.tsx +102 -53
- package/src/components/calendar.tsx +5 -4
- package/src/components/chart.tsx +4 -4
- package/src/components/dropdown-menu.tsx +5 -5
- package/src/components/empty-state.tsx +26 -0
- package/src/components/field.tsx +2 -2
- package/src/components/initials-avatar.tsx +30 -0
- package/src/components/input.tsx +1 -1
- package/src/components/kpi-breakdown-bar.tsx +71 -0
- package/src/components/label.tsx +1 -1
- package/src/components/pagination.tsx +2 -3
- package/src/components/password-input.tsx +10 -43
- package/src/components/password-strength.tsx +9 -10
- package/src/components/phone-input.tsx +114 -0
- package/src/components/popover.tsx +1 -1
- package/src/components/role-badge.tsx +34 -0
- package/src/components/search-field.tsx +63 -0
- package/src/components/section-heading.tsx +28 -0
- package/src/components/sheet.tsx +34 -5
- package/src/components/skeleton-table.tsx +27 -0
- package/src/components/skeleton.tsx +45 -0
- package/src/components/sonner.tsx +2 -1
- package/src/components/stat-tile.tsx +30 -0
- package/src/components/status-pill.tsx +37 -0
- package/src/components/surface-card.tsx +11 -0
- package/src/components/table-pagination.tsx +107 -0
- package/src/components/table.tsx +7 -4
- package/src/components/theme-provider.tsx +130 -0
- package/src/components/tooltip.tsx +2 -2
- package/src/index.ts +22 -1
- package/src/lib/patterns.ts +52 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
export type EmptyStateProps = HTMLAttributes<HTMLDivElement> & {
|
|
7
|
+
icon: LucideIcon;
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
hint?: ReactNode;
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function EmptyState({ icon: Icon, title, hint, action, className, ...props }: EmptyStateProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className={cn("flex flex-col items-center justify-center gap-3 px-6 py-14 text-center", className)} {...props}>
|
|
16
|
+
<div className="flex size-11 items-center justify-center rounded-lg border border-hairline bg-muted">
|
|
17
|
+
<Icon className="size-5 text-muted-foreground" aria-hidden="true" />
|
|
18
|
+
</div>
|
|
19
|
+
<div>
|
|
20
|
+
<p className="text-ui-body font-semibold">{title}</p>
|
|
21
|
+
{hint ? <p className="mt-1 text-ui-meta">{hint}</p> : null}
|
|
22
|
+
</div>
|
|
23
|
+
{action ? <div className="mt-1">{action}</div> : null}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/components/field.tsx
CHANGED
|
@@ -31,7 +31,7 @@ function FieldLegend({
|
|
|
31
31
|
data-slot="field-legend"
|
|
32
32
|
data-variant={variant}
|
|
33
33
|
className={cn(
|
|
34
|
-
"mb-3 font-
|
|
34
|
+
"mb-3 font-semibold",
|
|
35
35
|
"data-[variant=legend]:text-base",
|
|
36
36
|
"data-[variant=label]:text-sm",
|
|
37
37
|
className
|
|
@@ -130,7 +130,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
130
130
|
<div
|
|
131
131
|
data-slot="field-label"
|
|
132
132
|
className={cn(
|
|
133
|
-
"flex w-fit items-center gap-2 text-sm leading-snug font-
|
|
133
|
+
"flex w-fit items-center gap-2 text-sm leading-snug font-semibold group-data-[disabled=true]/field:opacity-50",
|
|
134
134
|
className
|
|
135
135
|
)}
|
|
136
136
|
{...props}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
|
|
3
|
+
import { getInitials } from "../lib/patterns";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Avatar, AvatarFallback } from "./avatar";
|
|
6
|
+
|
|
7
|
+
export type InitialsAvatarTone = "active" | "subtle";
|
|
8
|
+
|
|
9
|
+
export type InitialsAvatarProps = Omit<ComponentProps<typeof Avatar>, "children"> & {
|
|
10
|
+
label?: string | null;
|
|
11
|
+
fallback?: string | null;
|
|
12
|
+
tone?: InitialsAvatarTone;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const toneClasses: Record<InitialsAvatarTone, string> = {
|
|
16
|
+
active: "bg-primary/15 text-primary",
|
|
17
|
+
subtle: "bg-muted text-muted-foreground",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function InitialsAvatar({ label, fallback, tone = "active", className, ...props }: InitialsAvatarProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Avatar className={cn("shrink-0", toneClasses[tone], className)} {...props}>
|
|
23
|
+
<AvatarFallback className="bg-transparent text-ui-meta font-bold uppercase text-current">
|
|
24
|
+
{getInitials(label, fallback)}
|
|
25
|
+
</AvatarFallback>
|
|
26
|
+
</Avatar>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { getInitials } from "../lib/patterns";
|
package/src/components/input.tsx
CHANGED
|
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
|
8
8
|
type={type}
|
|
9
9
|
data-slot="input"
|
|
10
10
|
className={cn(
|
|
11
|
-
"file:text-foreground placeholder:text-foreground/30 selection:bg-primary selection:text-primary-foreground h-11 w-full min-w-0 rounded-xl border border-foreground/15 bg-foreground/5 px-3 py-1 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-input file:text-sm file:font-
|
|
11
|
+
"file:text-foreground placeholder:text-foreground/30 selection:bg-primary selection:text-primary-foreground h-11 w-full min-w-0 rounded-xl border border-foreground/15 bg-foreground/5 px-3 py-1 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-input file:text-sm file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
12
12
|
"focus:border-primary focus:ring-primary/40 focus:ring-[3px]",
|
|
13
13
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
14
14
|
className,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
7
|
+
|
|
8
|
+
export type KpiBreakdownItem = {
|
|
9
|
+
label: string;
|
|
10
|
+
tone: string;
|
|
11
|
+
value: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type KpiBreakdownBarProps = {
|
|
15
|
+
items: KpiBreakdownItem[];
|
|
16
|
+
toneTokens: Readonly<Record<string, string>>;
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function tokenStyle(token: string): CSSProperties {
|
|
21
|
+
return { background: `var(${token})` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function KpiBreakdownBar({ items, toneTokens, className }: KpiBreakdownBarProps) {
|
|
25
|
+
const total = items.reduce((sum, item) => sum + Math.max(0, item.value), 0);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn("mt-5", className)}>
|
|
29
|
+
<TooltipProvider delayDuration={80}>
|
|
30
|
+
<div className="flex h-2 overflow-hidden rounded-full bg-elevate-3">
|
|
31
|
+
{total > 0 ? items.map((item) => {
|
|
32
|
+
const token = toneTokens[item.tone];
|
|
33
|
+
if (item.value <= 0 || !token) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Tooltip key={`${item.tone}:${item.label}`}>
|
|
37
|
+
<TooltipTrigger asChild>
|
|
38
|
+
<span
|
|
39
|
+
className="h-full cursor-default transition-[filter] duration-150 first:rounded-l-full last:rounded-r-full hover:brightness-110"
|
|
40
|
+
style={{ ...tokenStyle(token), width: `${(item.value / total) * 100}%` }}
|
|
41
|
+
/>
|
|
42
|
+
</TooltipTrigger>
|
|
43
|
+
<TooltipContent>
|
|
44
|
+
<span className="flex items-center gap-2">
|
|
45
|
+
<span className="size-2 rounded-[2px]" style={tokenStyle(token)} />
|
|
46
|
+
<span>{item.label}</span>
|
|
47
|
+
<span className="font-bold tabular-nums">{item.value}</span>
|
|
48
|
+
</span>
|
|
49
|
+
</TooltipContent>
|
|
50
|
+
</Tooltip>
|
|
51
|
+
);
|
|
52
|
+
}) : null}
|
|
53
|
+
</div>
|
|
54
|
+
</TooltipProvider>
|
|
55
|
+
<div className="mt-3.5 grid grid-cols-2 gap-x-6 gap-y-1.5">
|
|
56
|
+
{items.map((item) => {
|
|
57
|
+
const token = toneTokens[item.tone];
|
|
58
|
+
return (
|
|
59
|
+
<div key={`${item.tone}:${item.label}`} className="flex items-center justify-between gap-2 text-ui-meta">
|
|
60
|
+
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
|
61
|
+
<span className="size-1.5 shrink-0 rounded-[2px]" style={token ? tokenStyle(token) : undefined} />
|
|
62
|
+
<span className="truncate">{item.label}</span>
|
|
63
|
+
</span>
|
|
64
|
+
<span className="font-mono font-bold tabular-nums text-foreground">{item.value}</span>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/components/label.tsx
CHANGED
|
@@ -13,7 +13,7 @@ function Label({
|
|
|
13
13
|
<LabelPrimitive.Root
|
|
14
14
|
data-slot="label"
|
|
15
15
|
className={cn(
|
|
16
|
-
"flex items-center gap-2 text-sm leading-none font-
|
|
16
|
+
"flex items-center gap-2 text-sm leading-none font-semibold select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
17
17
|
className
|
|
18
18
|
)}
|
|
19
19
|
{...props}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import Link from "next/link";
|
|
3
3
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
|
4
|
-
import { VariantProps } from "class-variance-authority";
|
|
5
4
|
|
|
6
5
|
import { cn } from "../lib/utils";
|
|
7
|
-
import { buttonVariants } from "./button";
|
|
6
|
+
import { buttonVariants, type ButtonVariantProps } from "./button-variants";
|
|
8
7
|
|
|
9
8
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
|
10
9
|
return (
|
|
@@ -32,7 +31,7 @@ function PaginationItem(props: React.ComponentProps<"li">) {
|
|
|
32
31
|
|
|
33
32
|
type PaginationLinkProps = {
|
|
34
33
|
isActive?: boolean;
|
|
35
|
-
} & Pick<
|
|
34
|
+
} & Pick<ButtonVariantProps, "size"> &
|
|
36
35
|
React.ComponentProps<typeof Link>;
|
|
37
36
|
|
|
38
37
|
function PaginationLink({
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { Eye, EyeOff } from "lucide-react";
|
|
5
|
+
|
|
4
6
|
import { cn } from "../lib/utils";
|
|
5
7
|
import { Input } from "./input";
|
|
6
8
|
|
|
@@ -11,61 +13,26 @@ interface PasswordInputProps extends Omit<React.ComponentProps<"input">, "type">
|
|
|
11
13
|
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
12
14
|
({ className, disabled, showToggle = true, ...props }, ref) => {
|
|
13
15
|
const [showPassword, setShowPassword] = React.useState(false);
|
|
14
|
-
|
|
15
16
|
return (
|
|
16
17
|
<div className="relative w-full">
|
|
17
|
-
<Input
|
|
18
|
-
|
|
19
|
-
className={cn(showToggle && "pr-12", className)}
|
|
20
|
-
disabled={disabled}
|
|
21
|
-
ref={ref}
|
|
22
|
-
{...props}
|
|
23
|
-
/>
|
|
24
|
-
{showToggle && (
|
|
18
|
+
<Input type={showPassword ? "text" : "password"} className={cn(showToggle && "pr-12", className)} disabled={disabled} ref={ref} {...props} />
|
|
19
|
+
{showToggle ? (
|
|
25
20
|
<button
|
|
26
21
|
type="button"
|
|
27
22
|
onClick={() => setShowPassword((current) => !current)}
|
|
28
|
-
className="absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center text-foreground/40 transition-colors hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:pointer-events-none disabled:opacity-50"
|
|
23
|
+
className="absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-[var(--radius)] text-foreground/40 transition-colors hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:pointer-events-none disabled:opacity-50"
|
|
29
24
|
disabled={disabled}
|
|
30
|
-
aria-label={showPassword ? "
|
|
31
|
-
tabIndex={-1}
|
|
25
|
+
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
32
26
|
>
|
|
33
|
-
{showPassword ?
|
|
34
|
-
<svg
|
|
35
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
36
|
-
viewBox="0 0 24 24"
|
|
37
|
-
fill="none"
|
|
38
|
-
stroke="currentColor"
|
|
39
|
-
strokeWidth="2"
|
|
40
|
-
strokeLinecap="round"
|
|
41
|
-
strokeLinejoin="round"
|
|
42
|
-
className="w-4 h-4"
|
|
43
|
-
>
|
|
44
|
-
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
|
45
|
-
<line x1="1" y1="1" x2="23" y2="23" />
|
|
46
|
-
</svg>
|
|
47
|
-
) : (
|
|
48
|
-
<svg
|
|
49
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
50
|
-
viewBox="0 0 24 24"
|
|
51
|
-
fill="none"
|
|
52
|
-
stroke="currentColor"
|
|
53
|
-
strokeWidth="2"
|
|
54
|
-
strokeLinecap="round"
|
|
55
|
-
strokeLinejoin="round"
|
|
56
|
-
className="w-4 h-4"
|
|
57
|
-
>
|
|
58
|
-
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
59
|
-
<circle cx="12" cy="12" r="3" />
|
|
60
|
-
</svg>
|
|
61
|
-
)}
|
|
27
|
+
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
62
28
|
</button>
|
|
63
|
-
)}
|
|
29
|
+
) : null}
|
|
64
30
|
</div>
|
|
65
31
|
);
|
|
66
|
-
}
|
|
32
|
+
},
|
|
67
33
|
);
|
|
68
34
|
|
|
69
35
|
PasswordInput.displayName = "PasswordInput";
|
|
70
36
|
|
|
71
37
|
export { PasswordInput };
|
|
38
|
+
export type { PasswordInputProps };
|
|
@@ -36,23 +36,23 @@ function getStrengthConfig(strength: StrengthLevel) {
|
|
|
36
36
|
case "weak":
|
|
37
37
|
return {
|
|
38
38
|
label: "Fraca",
|
|
39
|
-
color: "bg-
|
|
39
|
+
color: "bg-rose-500",
|
|
40
40
|
width: "w-1/3",
|
|
41
|
-
textColor: "text-
|
|
41
|
+
textColor: "text-rose-600 dark:text-rose-400",
|
|
42
42
|
};
|
|
43
43
|
case "medium":
|
|
44
44
|
return {
|
|
45
45
|
label: "Média",
|
|
46
|
-
color: "bg-
|
|
46
|
+
color: "bg-amber-500",
|
|
47
47
|
width: "w-2/3",
|
|
48
|
-
textColor: "text-
|
|
48
|
+
textColor: "text-amber-600 dark:text-amber-400",
|
|
49
49
|
};
|
|
50
50
|
case "strong":
|
|
51
51
|
return {
|
|
52
52
|
label: "Forte",
|
|
53
|
-
color: "bg-
|
|
53
|
+
color: "bg-emerald-500",
|
|
54
54
|
width: "w-full",
|
|
55
|
-
textColor: "text-
|
|
55
|
+
textColor: "text-emerald-600 dark:text-emerald-400",
|
|
56
56
|
};
|
|
57
57
|
default:
|
|
58
58
|
return null;
|
|
@@ -67,8 +67,7 @@ export function PasswordStrength({ password, className }: PasswordStrengthProps)
|
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
69
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
70
|
-
|
|
71
|
-
<div className="h-1 w-full bg-foreground/10 rounded-full overflow-hidden">
|
|
70
|
+
<div className="h-1 w-full overflow-hidden rounded-full bg-foreground/10">
|
|
72
71
|
<div
|
|
73
72
|
className={cn("h-full transition-all duration-300", config.color, config.width)}
|
|
74
73
|
role="progressbar"
|
|
@@ -76,11 +75,11 @@ export function PasswordStrength({ password, className }: PasswordStrengthProps)
|
|
|
76
75
|
aria-valuemin={0}
|
|
77
76
|
aria-valuemax={100}
|
|
78
77
|
aria-label="Força da palavra-passe"
|
|
78
|
+
aria-valuetext={config.label}
|
|
79
79
|
/>
|
|
80
80
|
</div>
|
|
81
81
|
|
|
82
|
-
{
|
|
83
|
-
<p className={cn("paragraph-mini font-medium transition-colors", config.textColor)}>
|
|
82
|
+
<p className={cn("text-ui-meta !font-semibold transition-colors", config.textColor)}>
|
|
84
83
|
Força: {config.label}
|
|
85
84
|
</p>
|
|
86
85
|
</div>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { defaultCountries, parseCountry, usePhoneInput } from "react-international-phone";
|
|
5
|
+
import type { CountryIso2 } from "react-international-phone";
|
|
6
|
+
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
interface PhoneInputProps {
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (e164: string) => void;
|
|
12
|
+
onBlur?: () => void;
|
|
13
|
+
defaultCountry?: CountryIso2;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PREFERRED_COUNTRIES: CountryIso2[] = ["pt", "es", "gb", "fr", "de", "br", "us"];
|
|
19
|
+
const allCountries = defaultCountries.map((country) => parseCountry(country));
|
|
20
|
+
const preferredCountries = PREFERRED_COUNTRIES.map((iso) => allCountries.find((country) => country.iso2 === iso)).filter(Boolean);
|
|
21
|
+
const remainingCountries = allCountries.filter((country) => !PREFERRED_COUNTRIES.includes(country.iso2));
|
|
22
|
+
const orderedCountries = [...preferredCountries, ...remainingCountries] as ReturnType<typeof parseCountry>[];
|
|
23
|
+
|
|
24
|
+
function flagEmoji(iso2: string) {
|
|
25
|
+
return iso2.toUpperCase().split("").map((character) => String.fromCodePoint(0x1f1e6 - 0x41 + character.charCodeAt(0))).join("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PhoneInput({ value, onChange, onBlur, defaultCountry = "pt", disabled = false, className }: PhoneInputProps) {
|
|
29
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
30
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
31
|
+
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const [search, setSearch] = useState("");
|
|
34
|
+
|
|
35
|
+
const { inputValue, handlePhoneValueChange, country, setCountry } = usePhoneInput({
|
|
36
|
+
defaultCountry,
|
|
37
|
+
value,
|
|
38
|
+
forceDialCode: true,
|
|
39
|
+
onChange: ({ phone }) => onChange(phone),
|
|
40
|
+
inputRef,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
const handleOutsideClick = (event: MouseEvent) => {
|
|
46
|
+
if (!dropdownRef.current?.contains(event.target as Node) && !triggerRef.current?.contains(event.target as Node)) {
|
|
47
|
+
setOpen(false);
|
|
48
|
+
setSearch("");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener("mousedown", handleOutsideClick);
|
|
52
|
+
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
|
53
|
+
}, [open]);
|
|
54
|
+
|
|
55
|
+
const filteredCountries = search
|
|
56
|
+
? orderedCountries.filter((candidate) => candidate.name.toLowerCase().includes(search.toLowerCase()) || `+${candidate.dialCode}`.includes(search))
|
|
57
|
+
: orderedCountries;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn("relative flex items-center", className)}>
|
|
61
|
+
<button
|
|
62
|
+
ref={triggerRef}
|
|
63
|
+
type="button"
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
onClick={() => setOpen((current) => !current)}
|
|
66
|
+
className="flex h-7 items-center gap-1 border-0 bg-transparent pr-1.5 text-sm disabled:pointer-events-none disabled:opacity-100"
|
|
67
|
+
aria-expanded={open}
|
|
68
|
+
aria-label="Choose country code"
|
|
69
|
+
>
|
|
70
|
+
<span className="text-base leading-none">{flagEmoji(country.iso2)}</span>
|
|
71
|
+
<svg className="size-2.5 text-foreground/40" viewBox="0 0 10 6" fill="none" aria-hidden>
|
|
72
|
+
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
73
|
+
</svg>
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
{open ? (
|
|
77
|
+
<div ref={dropdownRef} className="absolute left-0 top-full z-50 mt-1.5 w-56 rounded-[var(--radius-card)] border border-[color:var(--hairline-strong)] bg-popover text-popover-foreground shadow-[0_12px_28px_var(--elevate-3)]">
|
|
78
|
+
<div className="border-b border-[color:var(--hairline)] px-2.5 py-2">
|
|
79
|
+
<input autoFocus value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search…" className="w-full border-0 bg-transparent text-ui-meta text-foreground outline-none placeholder:text-foreground/40" />
|
|
80
|
+
</div>
|
|
81
|
+
<div className="max-h-48 overflow-y-auto rounded-b-[var(--radius-card)] p-1">
|
|
82
|
+
{filteredCountries.map((candidate) => (
|
|
83
|
+
<button
|
|
84
|
+
key={candidate.iso2}
|
|
85
|
+
type="button"
|
|
86
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
87
|
+
onClick={() => {
|
|
88
|
+
setCountry(candidate.iso2);
|
|
89
|
+
setOpen(false);
|
|
90
|
+
setSearch("");
|
|
91
|
+
inputRef.current?.focus();
|
|
92
|
+
}}
|
|
93
|
+
className={cn(
|
|
94
|
+
"flex w-full items-center gap-2 rounded-[var(--radius)] px-2 py-1 text-left transition-colors hover:bg-[color:var(--elevate-2)]",
|
|
95
|
+
country.iso2 === candidate.iso2 && "bg-[color:var(--elevate-3)] text-[color:var(--brand-accent)]",
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<span className="text-sm leading-none">{flagEmoji(candidate.iso2)}</span>
|
|
99
|
+
<span className="flex-1 truncate text-ui-label normal-case tracking-normal text-foreground">{candidate.name}</span>
|
|
100
|
+
<span className="shrink-0 text-ui-micro text-foreground/40">+{candidate.dialCode}</span>
|
|
101
|
+
</button>
|
|
102
|
+
))}
|
|
103
|
+
{filteredCountries.length === 0 ? <p className="px-2 py-2 text-ui-meta text-foreground/45">No results</p> : null}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
<span className="h-4 w-px shrink-0 bg-foreground/15" />
|
|
109
|
+
<input ref={inputRef} value={inputValue} onChange={handlePhoneValueChange} onBlur={onBlur} disabled={disabled} placeholder="912 345 678" type="tel" className="h-7 flex-1 border-0 bg-transparent pl-2 text-sm text-foreground outline-none placeholder:text-foreground/35 disabled:pointer-events-none disabled:opacity-100" />
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type { PhoneInputProps };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
|
|
3
|
+
import { getRoleLabel, resolveRoleToken } from "../lib/patterns";
|
|
4
|
+
import { StatusPill } from "./status-pill";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_ROLE_TOKEN_MAP = {
|
|
7
|
+
admin: "--role-admin",
|
|
8
|
+
client: "--role-client",
|
|
9
|
+
manager: "--role-manager",
|
|
10
|
+
team: "--role-team",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type RoleBadgeProps = Omit<ComponentProps<typeof StatusPill>, "children" | "token"> & {
|
|
14
|
+
role: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
tokenMap?: Readonly<Record<string, string>>;
|
|
17
|
+
fallbackToken?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function RoleBadge({
|
|
21
|
+
role,
|
|
22
|
+
label,
|
|
23
|
+
tokenMap = DEFAULT_ROLE_TOKEN_MAP,
|
|
24
|
+
fallbackToken = "--semantic-neutral",
|
|
25
|
+
...props
|
|
26
|
+
}: RoleBadgeProps) {
|
|
27
|
+
return (
|
|
28
|
+
<StatusPill token={resolveRoleToken(role, tokenMap, fallbackToken)} {...props}>
|
|
29
|
+
{label ?? getRoleLabel(role)}
|
|
30
|
+
</StatusPill>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { getRoleLabel, resolveRoleToken } from "../lib/patterns";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Search, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Input } from "./input";
|
|
7
|
+
|
|
8
|
+
type SearchFieldProps = {
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
onClear?: () => void;
|
|
13
|
+
size?: "sm" | "md";
|
|
14
|
+
className?: string;
|
|
15
|
+
inputClassName?: string;
|
|
16
|
+
autoFocus?: boolean;
|
|
17
|
+
"aria-label"?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function SearchField({
|
|
21
|
+
value,
|
|
22
|
+
onChange,
|
|
23
|
+
placeholder = "Search…",
|
|
24
|
+
onClear,
|
|
25
|
+
size = "md",
|
|
26
|
+
className,
|
|
27
|
+
inputClassName,
|
|
28
|
+
autoFocus,
|
|
29
|
+
"aria-label": ariaLabel,
|
|
30
|
+
}: SearchFieldProps) {
|
|
31
|
+
const showClear = Boolean(onClear) && value.length > 0;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={cn("relative min-w-0 flex-1", className)}>
|
|
35
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
|
36
|
+
<Input
|
|
37
|
+
value={value}
|
|
38
|
+
onChange={(event) => onChange(event.target.value)}
|
|
39
|
+
placeholder={placeholder}
|
|
40
|
+
autoFocus={autoFocus}
|
|
41
|
+
aria-label={ariaLabel ?? placeholder}
|
|
42
|
+
className={cn(
|
|
43
|
+
size === "sm" ? "h-8" : "h-9",
|
|
44
|
+
"rounded-[var(--radius-card)] border-border bg-card pl-8 text-ui-meta text-foreground",
|
|
45
|
+
showClear ? "pr-8" : null,
|
|
46
|
+
inputClassName,
|
|
47
|
+
)}
|
|
48
|
+
/>
|
|
49
|
+
{showClear ? (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={onClear}
|
|
53
|
+
aria-label="Clear"
|
|
54
|
+
className="absolute right-2 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
55
|
+
>
|
|
56
|
+
<X className="size-3.5" />
|
|
57
|
+
</button>
|
|
58
|
+
) : null}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type { SearchFieldProps };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
export type SectionHeadingProps = HTMLAttributes<HTMLDivElement> & {
|
|
7
|
+
icon: LucideIcon;
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
subtitle?: ReactNode;
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function SectionHeading({ icon: Icon, title, subtitle, action, className, ...props }: SectionHeadingProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className={cn("flex flex-wrap items-end justify-between gap-3", className)} {...props}>
|
|
16
|
+
<div className="flex min-w-0 items-start gap-3">
|
|
17
|
+
<span className="section-icon mt-0.5" aria-hidden="true">
|
|
18
|
+
<Icon className="size-3.5" />
|
|
19
|
+
</span>
|
|
20
|
+
<div className="min-w-0">
|
|
21
|
+
<h2 className="text-ui-panel-title">{title}</h2>
|
|
22
|
+
{subtitle ? <p className="mt-1 text-ui-body text-muted-foreground">{subtitle}</p> : null}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
{action ? <div className="shrink-0">{action}</div> : null}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
package/src/components/sheet.tsx
CHANGED
|
@@ -6,8 +6,37 @@ import { XIcon } from "lucide-react"
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "../lib/utils"
|
|
8
8
|
|
|
9
|
-
function
|
|
10
|
-
return
|
|
9
|
+
function hasOpenModalContent() {
|
|
10
|
+
return Boolean(
|
|
11
|
+
document.querySelector(
|
|
12
|
+
['[data-slot="sheet-content"][data-state="open"]', '[data-slot="alert-dialog-content"][data-state="open"]'].join(", ")
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function releaseStaleBodyPointerLock() {
|
|
18
|
+
window.setTimeout(() => {
|
|
19
|
+
if (hasOpenModalContent()) return
|
|
20
|
+
if (document.body.style.pointerEvents === "none") {
|
|
21
|
+
document.body.style.pointerEvents = ""
|
|
22
|
+
}
|
|
23
|
+
}, 350)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Sheet({ onOpenChange, ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|
27
|
+
const handleOpenChange = React.useCallback(
|
|
28
|
+
(open: boolean) => {
|
|
29
|
+
onOpenChange?.(open)
|
|
30
|
+
if (!open) releaseStaleBodyPointerLock()
|
|
31
|
+
},
|
|
32
|
+
[onOpenChange]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
if (props.open === false) releaseStaleBodyPointerLock()
|
|
37
|
+
}, [props.open])
|
|
38
|
+
|
|
39
|
+
return <SheetPrimitive.Root data-slot="sheet" onOpenChange={handleOpenChange} {...props} />
|
|
11
40
|
}
|
|
12
41
|
|
|
13
42
|
function SheetTrigger({
|
|
@@ -36,7 +65,7 @@ function SheetOverlay({
|
|
|
36
65
|
<SheetPrimitive.Overlay
|
|
37
66
|
data-slot="sheet-overlay"
|
|
38
67
|
className={cn(
|
|
39
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-
|
|
68
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-[color:color-mix(in_srgb,var(--foreground)_50%,transparent)]",
|
|
40
69
|
className
|
|
41
70
|
)}
|
|
42
71
|
{...props}
|
|
@@ -62,9 +91,9 @@ function SheetContent({
|
|
|
62
91
|
className={cn(
|
|
63
92
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
|
64
93
|
side === "right" &&
|
|
65
|
-
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-
|
|
94
|
+
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-[24rem]",
|
|
66
95
|
side === "left" &&
|
|
67
|
-
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-
|
|
96
|
+
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-[24rem]",
|
|
68
97
|
side === "top" &&
|
|
69
98
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
|
70
99
|
side === "bottom" &&
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Skeleton } from "./skeleton";
|
|
2
|
+
import { TableCell, TableRow } from "./table";
|
|
3
|
+
|
|
4
|
+
export function TableRowsSkeleton({ rows = 6, columns }: {
|
|
5
|
+
rows?: number;
|
|
6
|
+
columns: ("text" | "chip" | "action")[];
|
|
7
|
+
}) {
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
{Array.from({ length: rows }, (_, rowIndex) => (
|
|
11
|
+
<TableRow key={rowIndex}>
|
|
12
|
+
{columns.map((kind, columnIndex) => (
|
|
13
|
+
<TableCell key={columnIndex}>
|
|
14
|
+
{kind === "chip" ? (
|
|
15
|
+
<Skeleton rounded="999px" className="h-5 w-14" />
|
|
16
|
+
) : kind === "action" ? (
|
|
17
|
+
<Skeleton rounded="var(--radius)" className="ml-auto size-7" />
|
|
18
|
+
) : (
|
|
19
|
+
<Skeleton rounded="999px" className="h-[0.6rem] w-[72%]" />
|
|
20
|
+
)}
|
|
21
|
+
</TableCell>
|
|
22
|
+
))}
|
|
23
|
+
</TableRow>
|
|
24
|
+
))}
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
}
|