@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.
Files changed (38) hide show
  1. package/package.json +43 -7
  2. package/src/components/action.tsx +12 -0
  3. package/src/components/activity-message.tsx +1 -1
  4. package/src/components/alert-dialog.tsx +4 -4
  5. package/src/components/avatar.tsx +80 -0
  6. package/src/components/badge.tsx +20 -30
  7. package/src/components/button-variants.ts +47 -0
  8. package/src/components/button.tsx +102 -53
  9. package/src/components/calendar.tsx +5 -4
  10. package/src/components/chart.tsx +4 -4
  11. package/src/components/dropdown-menu.tsx +5 -5
  12. package/src/components/empty-state.tsx +26 -0
  13. package/src/components/field.tsx +2 -2
  14. package/src/components/initials-avatar.tsx +30 -0
  15. package/src/components/input.tsx +1 -1
  16. package/src/components/kpi-breakdown-bar.tsx +71 -0
  17. package/src/components/label.tsx +1 -1
  18. package/src/components/pagination.tsx +2 -3
  19. package/src/components/password-input.tsx +10 -43
  20. package/src/components/password-strength.tsx +9 -10
  21. package/src/components/phone-input.tsx +114 -0
  22. package/src/components/popover.tsx +1 -1
  23. package/src/components/role-badge.tsx +34 -0
  24. package/src/components/search-field.tsx +63 -0
  25. package/src/components/section-heading.tsx +28 -0
  26. package/src/components/sheet.tsx +34 -5
  27. package/src/components/skeleton-table.tsx +27 -0
  28. package/src/components/skeleton.tsx +45 -0
  29. package/src/components/sonner.tsx +2 -1
  30. package/src/components/stat-tile.tsx +30 -0
  31. package/src/components/status-pill.tsx +37 -0
  32. package/src/components/surface-card.tsx +11 -0
  33. package/src/components/table-pagination.tsx +107 -0
  34. package/src/components/table.tsx +7 -4
  35. package/src/components/theme-provider.tsx +130 -0
  36. package/src/components/tooltip.tsx +2 -2
  37. package/src/index.ts +22 -1
  38. 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
+ }
@@ -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-medium",
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-medium group-data-[disabled=true]/field:opacity-50",
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";
@@ -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-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
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
+ }
@@ -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-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
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<VariantProps<typeof buttonVariants>, "size"> &
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
- type={showPassword ? "text" : "password"}
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 ? "Ocultar palavra-passe" : "Mostrar palavra-passe"}
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-destructive",
39
+ color: "bg-rose-500",
40
40
  width: "w-1/3",
41
- textColor: "text-destructive",
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-accent",
46
+ color: "bg-amber-500",
47
47
  width: "w-2/3",
48
- textColor: "text-accent",
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-secondary",
53
+ color: "bg-emerald-500",
54
54
  width: "w-full",
55
- textColor: "text-secondary",
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
- {/* Strength bar */}
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
- {/* Strength label */}
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 };
@@ -59,7 +59,7 @@ function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
59
59
  return (
60
60
  <div
61
61
  data-slot="popover-title"
62
- className={cn("font-medium", className)}
62
+ className={cn("font-semibold", className)}
63
63
  {...props}
64
64
  />
65
65
  )
@@ -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
+ }
@@ -6,8 +6,37 @@ import { XIcon } from "lucide-react"
6
6
 
7
7
  import { cn } from "../lib/utils"
8
8
 
9
- function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
10
- return <SheetPrimitive.Root data-slot="sheet" {...props} />
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-black/50",
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-sm",
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-sm",
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
+ }