@icomponents/react 0.1.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 (117) hide show
  1. package/README.md +40 -0
  2. package/package.json +165 -0
  3. package/src/assets/custom/icon-dir.tsx +105 -0
  4. package/src/assets/custom/icon-layout-compact.tsx +84 -0
  5. package/src/assets/custom/icon-layout-default.tsx +48 -0
  6. package/src/assets/custom/icon-layout-full.tsx +68 -0
  7. package/src/assets/custom/icon-sidebar-floating.tsx +62 -0
  8. package/src/assets/custom/icon-sidebar-inset.tsx +53 -0
  9. package/src/assets/custom/icon-sidebar-sidebar.tsx +48 -0
  10. package/src/assets/custom/icon-theme-dark.tsx +36 -0
  11. package/src/assets/custom/icon-theme-light.tsx +35 -0
  12. package/src/assets/custom/icon-theme-system.tsx +113 -0
  13. package/src/components/admin-page-header.tsx +29 -0
  14. package/src/components/color-theme-switch.tsx +93 -0
  15. package/src/components/coming-soon.tsx +16 -0
  16. package/src/components/command-menu.tsx +98 -0
  17. package/src/components/config-drawer.tsx +401 -0
  18. package/src/components/confirm-dialog.dom.test.tsx +60 -0
  19. package/src/components/confirm-dialog.tsx +75 -0
  20. package/src/components/data-table/bulk-actions.tsx +193 -0
  21. package/src/components/data-table/column-header.tsx +66 -0
  22. package/src/components/data-table/data-table.dom.test.tsx +54 -0
  23. package/src/components/data-table/data-table.tsx +189 -0
  24. package/src/components/data-table/faceted-filter.tsx +156 -0
  25. package/src/components/data-table/index.ts +5 -0
  26. package/src/components/data-table/pagination.tsx +127 -0
  27. package/src/components/data-table/tanstack-table.d.ts +12 -0
  28. package/src/components/data-table/toolbar.tsx +88 -0
  29. package/src/components/data-table/view-options.tsx +49 -0
  30. package/src/components/date-picker.tsx +39 -0
  31. package/src/components/detail-card.tsx +63 -0
  32. package/src/components/language-switch.tsx +38 -0
  33. package/src/components/layout/app-sidebar.tsx +59 -0
  34. package/src/components/layout/app-title.tsx +62 -0
  35. package/src/components/layout/header.tsx +61 -0
  36. package/src/components/layout/main.tsx +34 -0
  37. package/src/components/layout/nav-group.tsx +185 -0
  38. package/src/components/layout/nav-user.tsx +115 -0
  39. package/src/components/layout/screen-header.tsx +119 -0
  40. package/src/components/layout/team-switcher.tsx +84 -0
  41. package/src/components/layout/top-nav.tsx +68 -0
  42. package/src/components/layout/types.ts +44 -0
  43. package/src/components/learn-more.tsx +35 -0
  44. package/src/components/long-text.tsx +80 -0
  45. package/src/components/navigation-progress.tsx +18 -0
  46. package/src/components/password-input.tsx +36 -0
  47. package/src/components/profile-dropdown.tsx +106 -0
  48. package/src/components/search.tsx +38 -0
  49. package/src/components/select-dropdown.tsx +62 -0
  50. package/src/components/sign-out-dialog.tsx +31 -0
  51. package/src/components/skip-to-main.tsx +10 -0
  52. package/src/components/stat-tile.tsx +56 -0
  53. package/src/components/status-badge.test.ts +23 -0
  54. package/src/components/status-badge.tsx +49 -0
  55. package/src/components/theme-switch.tsx +61 -0
  56. package/src/components/ui/alert-dialog.tsx +134 -0
  57. package/src/components/ui/alert.tsx +59 -0
  58. package/src/components/ui/avatar.tsx +38 -0
  59. package/src/components/ui/badge.tsx +39 -0
  60. package/src/components/ui/base-ui-state-attrs.dom.test.tsx +80 -0
  61. package/src/components/ui/button.tsx +51 -0
  62. package/src/components/ui/calendar.tsx +174 -0
  63. package/src/components/ui/card.tsx +74 -0
  64. package/src/components/ui/checkbox.tsx +26 -0
  65. package/src/components/ui/collapsible.tsx +18 -0
  66. package/src/components/ui/command.tsx +160 -0
  67. package/src/components/ui/dialog.tsx +129 -0
  68. package/src/components/ui/dropdown-menu.tsx +254 -0
  69. package/src/components/ui/form.tsx +155 -0
  70. package/src/components/ui/input-otp.tsx +67 -0
  71. package/src/components/ui/input.tsx +20 -0
  72. package/src/components/ui/label.tsx +21 -0
  73. package/src/components/ui/popover.tsx +54 -0
  74. package/src/components/ui/radio-group.tsx +37 -0
  75. package/src/components/ui/scroll-area.tsx +57 -0
  76. package/src/components/ui/select.tsx +213 -0
  77. package/src/components/ui/separator.tsx +23 -0
  78. package/src/components/ui/sheet.tsx +128 -0
  79. package/src/components/ui/sidebar.tsx +702 -0
  80. package/src/components/ui/skeleton.tsx +13 -0
  81. package/src/components/ui/sonner.tsx +26 -0
  82. package/src/components/ui/switch.tsx +25 -0
  83. package/src/components/ui/table.tsx +89 -0
  84. package/src/components/ui/tabs.tsx +51 -0
  85. package/src/components/ui/textarea.tsx +17 -0
  86. package/src/components/ui/tooltip.tsx +65 -0
  87. package/src/config/fonts.ts +2 -0
  88. package/src/context/direction-provider.tsx +61 -0
  89. package/src/context/font-provider.tsx +70 -0
  90. package/src/context/language-provider.tsx +73 -0
  91. package/src/context/layout-provider.tsx +81 -0
  92. package/src/context/search-provider.tsx +48 -0
  93. package/src/context/theme-provider.tsx +108 -0
  94. package/src/hooks/sort-url.test.ts +16 -0
  95. package/src/hooks/use-dialog-state.tsx +15 -0
  96. package/src/hooks/use-mobile.tsx +16 -0
  97. package/src/hooks/use-table-url-state.ts +243 -0
  98. package/src/i18n/common.ts +164 -0
  99. package/src/i18n/create-i18n.test.ts +50 -0
  100. package/src/i18n/create-i18n.ts +67 -0
  101. package/src/i18n/types.ts +22 -0
  102. package/src/i18n/use-translation.ts +6 -0
  103. package/src/lib/clipboard.test.ts +46 -0
  104. package/src/lib/clipboard.ts +29 -0
  105. package/src/lib/cookies.ts +39 -0
  106. package/src/lib/format-cell.test.ts +57 -0
  107. package/src/lib/format-cell.ts +63 -0
  108. package/src/lib/handle-server-error.test.ts +85 -0
  109. package/src/lib/handle-server-error.ts +39 -0
  110. package/src/lib/random-id.ts +13 -0
  111. package/src/lib/show-submitted-data.tsx +14 -0
  112. package/src/lib/utils.test.ts +35 -0
  113. package/src/lib/utils.ts +78 -0
  114. package/src/secure-context.contract.test.ts +63 -0
  115. package/src/testids.contract.test.ts +113 -0
  116. package/src/testids.ts +8 -0
  117. package/src/theme-contract.test.ts +72 -0
@@ -0,0 +1,80 @@
1
+ import { Popover, PopoverContent, PopoverTrigger } from "@icomponents/react/components/ui/popover";
2
+ import {
3
+ Tooltip,
4
+ TooltipContent,
5
+ TooltipProvider,
6
+ TooltipTrigger,
7
+ } from "@icomponents/react/components/ui/tooltip";
8
+ import { cn } from "@icomponents/react/lib/utils";
9
+ import { useRef, useState } from "react";
10
+
11
+ type LongTextProps = {
12
+ children: React.ReactNode;
13
+ className?: string;
14
+ contentClassName?: string;
15
+ };
16
+
17
+ export function LongText({ children, className = "", contentClassName = "" }: LongTextProps) {
18
+ const ref = useRef<HTMLDivElement>(null);
19
+ const [isOverflown, setIsOverflown] = useState(false);
20
+
21
+ // Use ref callback to check overflow when element is mounted
22
+ const refCallback = (node: HTMLDivElement | null) => {
23
+ ref.current = node;
24
+ if (node && checkOverflow(node)) {
25
+ queueMicrotask(() => setIsOverflown(true));
26
+ }
27
+ };
28
+
29
+ if (!isOverflown)
30
+ return (
31
+ <div ref={refCallback} className={cn("truncate", className)}>
32
+ {children}
33
+ </div>
34
+ );
35
+
36
+ return (
37
+ <>
38
+ <div className="hidden sm:block">
39
+ <TooltipProvider delay={0}>
40
+ <Tooltip>
41
+ <TooltipTrigger
42
+ render={
43
+ <div ref={refCallback} className={cn("truncate", className)}>
44
+ {children}
45
+ </div>
46
+ }
47
+ />
48
+ <TooltipContent>
49
+ <p className={contentClassName}>{children}</p>
50
+ </TooltipContent>
51
+ </Tooltip>
52
+ </TooltipProvider>
53
+ </div>
54
+ <div className="sm:hidden">
55
+ <Popover>
56
+ <PopoverTrigger
57
+ render={
58
+ <div ref={refCallback} className={cn("truncate", className)}>
59
+ {children}
60
+ </div>
61
+ }
62
+ />
63
+ <PopoverContent className={cn("w-fit", contentClassName)}>
64
+ <p>{children}</p>
65
+ </PopoverContent>
66
+ </Popover>
67
+ </div>
68
+ </>
69
+ );
70
+ }
71
+
72
+ const checkOverflow = (textContainer: HTMLDivElement | null) => {
73
+ if (textContainer) {
74
+ return (
75
+ textContainer.offsetHeight < textContainer.scrollHeight ||
76
+ textContainer.offsetWidth < textContainer.scrollWidth
77
+ );
78
+ }
79
+ return false;
80
+ };
@@ -0,0 +1,18 @@
1
+ import { useRouterState } from "@tanstack/react-router";
2
+ import { useEffect, useRef } from "react";
3
+ import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar";
4
+
5
+ export function NavigationProgress() {
6
+ const ref = useRef<LoadingBarRef>(null);
7
+ const state = useRouterState();
8
+
9
+ useEffect(() => {
10
+ if (state.status === "pending") {
11
+ ref.current?.continuousStart();
12
+ } else {
13
+ ref.current?.complete();
14
+ }
15
+ }, [state.status]);
16
+
17
+ return <LoadingBar color="var(--muted-foreground)" ref={ref} shadow={true} height={2} />;
18
+ }
@@ -0,0 +1,36 @@
1
+ import { cn } from "@icomponents/react/lib/utils";
2
+ import { Eye, EyeOff } from "lucide-react";
3
+ import * as React from "react";
4
+
5
+ import { Button } from "./ui/button";
6
+
7
+ type PasswordInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> & {
8
+ ref?: React.Ref<HTMLInputElement>;
9
+ };
10
+
11
+ export function PasswordInput({ className, disabled, ref, ...props }: PasswordInputProps) {
12
+ const [showPassword, setShowPassword] = React.useState(false);
13
+
14
+ return (
15
+ <div className={cn("relative rounded-md", className)}>
16
+ <input
17
+ type={showPassword ? "text" : "password"}
18
+ className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
19
+ ref={ref}
20
+ disabled={disabled}
21
+ {...props}
22
+ />
23
+ <Button
24
+ type="button"
25
+ size="icon"
26
+ variant="ghost"
27
+ disabled={disabled}
28
+ className="absolute inset-e-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md text-muted-foreground"
29
+ onClick={() => setShowPassword((prev) => !prev)}
30
+ >
31
+ {showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
32
+ <span className="sr-only">{showPassword ? "Hide password" : "Show password"}</span>
33
+ </Button>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,106 @@
1
+ import { SignOutDialog } from "@icomponents/react/components/sign-out-dialog";
2
+ import { Avatar, AvatarFallback, AvatarImage } from "@icomponents/react/components/ui/avatar";
3
+ import { Button } from "@icomponents/react/components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuGroup,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuShortcut,
12
+ DropdownMenuTrigger,
13
+ } from "@icomponents/react/components/ui/dropdown-menu";
14
+ import useDialogState from "@icomponents/react/hooks/use-dialog-state";
15
+ import { Link, type LinkProps } from "@tanstack/react-router";
16
+ import { type ElementType } from "react";
17
+
18
+ export type ProfileMenuItem = {
19
+ title: string;
20
+ url: LinkProps["to"] | (string & {});
21
+ icon?: ElementType;
22
+ shortcut?: string;
23
+ };
24
+
25
+ /** The signed-in identity to show; comes from the live session, never hard-coded. */
26
+ export type ProfileUser = {
27
+ name: string;
28
+ email: string;
29
+ avatar?: string | null;
30
+ };
31
+
32
+ type ProfileDropdownProps = {
33
+ items: ProfileMenuItem[];
34
+ onSignOut: () => void;
35
+ user?: ProfileUser | null;
36
+ };
37
+
38
+ function initialsOf(name: string): string {
39
+ const letters = name
40
+ .split(/\s+/)
41
+ .filter(Boolean)
42
+ .map((w) => w[0])
43
+ .slice(0, 2)
44
+ .join("")
45
+ .toUpperCase();
46
+ return letters || "U";
47
+ }
48
+
49
+ export function ProfileDropdown({ items, onSignOut, user }: ProfileDropdownProps) {
50
+ const [open, setOpen] = useDialogState();
51
+ const name = user?.name?.trim() || "Account";
52
+ const email = user?.email?.trim() ?? "";
53
+
54
+ return (
55
+ <>
56
+ <DropdownMenu modal={false}>
57
+ <DropdownMenuTrigger
58
+ render={
59
+ <Button variant="ghost" className="relative h-8 w-8 rounded-full">
60
+ <Avatar className="h-8 w-8">
61
+ {user?.avatar ? <AvatarImage src={user.avatar} alt={name} /> : null}
62
+ <AvatarFallback>{initialsOf(name)}</AvatarFallback>
63
+ </Avatar>
64
+ </Button>
65
+ }
66
+ />
67
+ <DropdownMenuContent className="w-56" align="end">
68
+ <DropdownMenuLabel className="font-normal">
69
+ <div className="flex flex-col gap-1.5">
70
+ <p className="text-sm leading-none font-medium">{name}</p>
71
+ {email ? <p className="text-xs leading-none text-muted-foreground">{email}</p> : null}
72
+ </div>
73
+ </DropdownMenuLabel>
74
+ {items.length > 0 ? (
75
+ <>
76
+ <DropdownMenuSeparator />
77
+ <DropdownMenuGroup>
78
+ {items.map((item) => (
79
+ <DropdownMenuItem
80
+ key={item.title}
81
+ render={
82
+ <Link to={item.url}>
83
+ {item.icon && <item.icon />}
84
+ {item.title}
85
+ {item.shortcut && (
86
+ <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>
87
+ )}
88
+ </Link>
89
+ }
90
+ />
91
+ ))}
92
+ </DropdownMenuGroup>
93
+ </>
94
+ ) : null}
95
+ <DropdownMenuSeparator />
96
+ <DropdownMenuItem variant="destructive" onClick={() => setOpen(true)}>
97
+ Sign out
98
+ <DropdownMenuShortcut className="text-current">⇧⌘Q</DropdownMenuShortcut>
99
+ </DropdownMenuItem>
100
+ </DropdownMenuContent>
101
+ </DropdownMenu>
102
+
103
+ <SignOutDialog open={!!open} onOpenChange={setOpen} onSignOut={onSignOut} />
104
+ </>
105
+ );
106
+ }
@@ -0,0 +1,38 @@
1
+ import { useSearch } from "@icomponents/react/context/search-provider";
2
+ import { useTranslation } from "@icomponents/react/i18n/use-translation";
3
+ import { cn } from "@icomponents/react/lib/utils";
4
+ import { SearchIcon } from "lucide-react";
5
+
6
+ import { Button } from "./ui/button";
7
+
8
+ export function Search({
9
+ className = "",
10
+ placeholder,
11
+ ...props
12
+ }: React.ComponentProps<"button"> & { placeholder?: string }) {
13
+ const { setOpen } = useSearch();
14
+ const { t } = useTranslation("common");
15
+ const label = placeholder ?? t("search.button");
16
+ return (
17
+ <Button
18
+ {...props}
19
+ variant="outline"
20
+ className={cn(
21
+ "group relative h-8 w-full flex-1 justify-start rounded-md bg-muted/25 text-sm font-normal text-muted-foreground shadow-none hover:bg-accent sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64",
22
+ className
23
+ )}
24
+ aria-keyshortcuts="Meta+K Control+K"
25
+ onClick={() => setOpen(true)}
26
+ >
27
+ <SearchIcon
28
+ aria-hidden="true"
29
+ className="absolute inset-s-1.5 top-1/2 -translate-y-1/2"
30
+ size={16}
31
+ />
32
+ <span className="ms-4">{label}</span>
33
+ <kbd className="pointer-events-none absolute inset-e-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 select-none group-hover:bg-accent sm:flex">
34
+ <span className="text-xs">⌘</span>K
35
+ </kbd>
36
+ </Button>
37
+ );
38
+ }
@@ -0,0 +1,62 @@
1
+ import { FormControl } from "@icomponents/react/components/ui/form";
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+ } from "@icomponents/react/components/ui/select";
9
+ import { cn } from "@icomponents/react/lib/utils";
10
+ import { Loader } from "lucide-react";
11
+
12
+ type SelectDropdownProps = {
13
+ onValueChange?: (value: string) => void;
14
+ defaultValue: string | undefined;
15
+ placeholder?: string;
16
+ isPending?: boolean;
17
+ items: { label: string; value: string }[] | undefined;
18
+ disabled?: boolean;
19
+ className?: string;
20
+ isControlled?: boolean;
21
+ };
22
+
23
+ export function SelectDropdown({
24
+ defaultValue,
25
+ onValueChange,
26
+ isPending,
27
+ items,
28
+ placeholder,
29
+ disabled,
30
+ className = "",
31
+ isControlled = false,
32
+ }: SelectDropdownProps) {
33
+ const defaultState = isControlled
34
+ ? { value: defaultValue, onValueChange }
35
+ : { defaultValue, onValueChange };
36
+ return (
37
+ <Select {...defaultState}>
38
+ <FormControl>
39
+ <SelectTrigger disabled={disabled} className={cn(className)}>
40
+ <SelectValue placeholder={placeholder ?? "Select"} />
41
+ </SelectTrigger>
42
+ </FormControl>
43
+ <SelectContent>
44
+ {isPending ? (
45
+ <SelectItem disabled value="loading" className="h-14">
46
+ <div className="flex items-center justify-center gap-2">
47
+ <Loader className="h-5 w-5 animate-spin" />
48
+ {" "}
49
+ Loading...
50
+ </div>
51
+ </SelectItem>
52
+ ) : (
53
+ items?.map(({ label, value }) => (
54
+ <SelectItem key={value} value={value}>
55
+ {label}
56
+ </SelectItem>
57
+ ))
58
+ )}
59
+ </SelectContent>
60
+ </Select>
61
+ );
62
+ }
@@ -0,0 +1,31 @@
1
+ import { ConfirmDialog } from "@icomponents/react/components/confirm-dialog";
2
+ import { useTranslation } from "@icomponents/react/i18n/use-translation";
3
+
4
+ interface SignOutDialogProps {
5
+ open: boolean;
6
+ onOpenChange: (open: boolean) => void;
7
+ onSignOut: () => void;
8
+ }
9
+
10
+ /**
11
+ * Подтверждение выхода. Подписи — из неймспейса "common" (бандл лежит рядом, в `../i18n/common.ts`):
12
+ * у компонента нет слайса-владельца, он стоит в шапке на всех экранах.
13
+ *
14
+ * Раньше строки были литералами по-английски, и в русском интерфейсе диалог выхода отвечал не на
15
+ * языке остальной консоли — на самом резком шаге, где человек и так торопится.
16
+ */
17
+ export function SignOutDialog({ open, onOpenChange, onSignOut }: SignOutDialogProps) {
18
+ const { t } = useTranslation("common");
19
+ return (
20
+ <ConfirmDialog
21
+ open={open}
22
+ onOpenChange={onOpenChange}
23
+ title={t("signOut.title")}
24
+ desc={t("signOut.desc")}
25
+ confirmText={t("signOut.confirm")}
26
+ destructive
27
+ handleConfirm={onSignOut}
28
+ className="sm:max-w-sm"
29
+ />
30
+ );
31
+ }
@@ -0,0 +1,10 @@
1
+ export function SkipToMain() {
2
+ return (
3
+ <a
4
+ className={`fixed inset-s-44 z-999 -translate-y-52 bg-primary px-4 py-2 text-sm font-medium whitespace-nowrap text-primary-foreground opacity-95 shadow-sm transition hover:bg-primary/90 focus:translate-y-3 focus:transform focus-visible:ring-1 focus-visible:ring-ring`}
5
+ href="#content"
6
+ >
7
+ Skip to Main
8
+ </a>
9
+ );
10
+ }
@@ -0,0 +1,56 @@
1
+ import type { StatusTone } from "@icomponents/react/components/status-badge";
2
+ import { cn } from "@icomponents/react/lib/utils";
3
+ import * as React from "react";
4
+
5
+ const TONE_DOT: Record<StatusTone, string> = {
6
+ success: "bg-success",
7
+ info: "bg-primary",
8
+ neutral: "bg-secondary-foreground",
9
+ warning: "bg-warning",
10
+ danger: "bg-destructive",
11
+ muted: "bg-muted-foreground",
12
+ };
13
+
14
+ // KPI / stat tile in the Evoca design language: a white soft-shadow card with a muted uppercase
15
+ // micro-label (optionally led by a status-hue dot) and a big condensed display number. Clickable
16
+ // (as a filter) when onClick is given — with an active highlight and a subtle hover lift. Reused by
17
+ // the companies registry (status pool filters) and the dashboard (overview KPIs).
18
+ export function StatTile({
19
+ label,
20
+ value,
21
+ tone,
22
+ active,
23
+ onClick,
24
+ className,
25
+ }: {
26
+ label: React.ReactNode;
27
+ value: React.ReactNode;
28
+ tone?: StatusTone;
29
+ active?: boolean;
30
+ onClick?: () => void;
31
+ className?: string;
32
+ }) {
33
+ const cls = cn(
34
+ "flex min-w-28 flex-col gap-1 rounded border bg-card px-4 py-3 text-left soft-shadow transition-transform",
35
+ onClick && "cursor-pointer hover:-translate-y-0.5",
36
+ active ? "border-primary ring-1 ring-primary/30" : "border-border",
37
+ className
38
+ );
39
+ const body = (
40
+ <>
41
+ <span className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
42
+ {tone ? <span className={cn("size-1.5 rounded-full", TONE_DOT[tone])} /> : null}
43
+ {label}
44
+ </span>
45
+ <span className="font-display text-3xl font-bold tabular-nums text-foreground">{value}</span>
46
+ </>
47
+ );
48
+ if (onClick) {
49
+ return (
50
+ <button type="button" onClick={onClick} data-active={active ? "" : undefined} className={cls}>
51
+ {body}
52
+ </button>
53
+ );
54
+ }
55
+ return <div className={cls}>{body}</div>;
56
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { statusBadgeVariants, type StatusTone } from "./status-badge";
4
+
5
+ describe("statusBadgeVariants — soft-tinted, one hue per state (semantic color-coding)", () => {
6
+ const cases: Record<StatusTone, string> = {
7
+ success: "success",
8
+ info: "primary",
9
+ neutral: "secondary-foreground",
10
+ warning: "warning",
11
+ danger: "destructive",
12
+ muted: "muted-foreground",
13
+ };
14
+
15
+ it("renders each tone as a same-hue tinted background + text (never solid-filled, never a hex literal)", () => {
16
+ for (const [tone, token] of Object.entries(cases) as [StatusTone, string][]) {
17
+ const cls = statusBadgeVariants({ tone });
18
+ expect(cls).toContain(`bg-${token}/10`);
19
+ expect(cls).toContain(`text-${token}`);
20
+ expect(cls).not.toMatch(/#[0-9a-fA-F]{3,6}/);
21
+ }
22
+ });
23
+ });
@@ -0,0 +1,49 @@
1
+ import { cn } from "@icomponents/react/lib/utils";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import * as React from "react";
4
+
5
+ // Soft-tinted status pill (Evoca design language): background at ~10% of the status hue + same-hue
6
+ // text + a small leading dot. ONE hue per state, applied consistently across companies / tenant
7
+ // users / audit — this disciplined semantic color-coding is what makes the console read as
8
+ // "colorful" rather than a single flat grey badge. Tones map onto existing theme tokens only
9
+ // (no raw color literals — RE-THEME): success/primary/secondary-foreground/warning/destructive/
10
+ // muted-foreground.
11
+ const statusBadgeVariants = cva(
12
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap",
13
+ {
14
+ variants: {
15
+ tone: {
16
+ success: "bg-success/10 text-success",
17
+ info: "bg-primary/10 text-primary",
18
+ neutral: "bg-secondary-foreground/10 text-secondary-foreground",
19
+ warning: "bg-warning/10 text-warning",
20
+ danger: "bg-destructive/10 text-destructive",
21
+ muted: "bg-muted-foreground/10 text-muted-foreground",
22
+ },
23
+ },
24
+ defaultVariants: { tone: "muted" },
25
+ }
26
+ );
27
+
28
+ export type StatusTone = NonNullable<VariantProps<typeof statusBadgeVariants>["tone"]>;
29
+
30
+ export function StatusBadge({
31
+ tone,
32
+ children,
33
+ dot = true,
34
+ className,
35
+ ...props
36
+ }: React.ComponentProps<"span"> & VariantProps<typeof statusBadgeVariants> & { dot?: boolean }) {
37
+ return (
38
+ <span
39
+ data-slot="status-badge"
40
+ className={cn(statusBadgeVariants({ tone }), className)}
41
+ {...props}
42
+ >
43
+ {dot ? <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden /> : null}
44
+ {children}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ export { statusBadgeVariants };
@@ -0,0 +1,61 @@
1
+ import { Button } from "@icomponents/react/components/ui/button";
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuTrigger,
7
+ } from "@icomponents/react/components/ui/dropdown-menu";
8
+ import { useTheme } from "@icomponents/react/context/theme-provider";
9
+ import { useTranslation } from "@icomponents/react/i18n/use-translation";
10
+ import { cn } from "@icomponents/react/lib/utils";
11
+ import { Check, Moon, Sun } from "lucide-react";
12
+ import { useEffect } from "react";
13
+
14
+ export function ThemeSwitch() {
15
+ const { theme, setTheme } = useTheme();
16
+ const { t } = useTranslation("common");
17
+
18
+ /**
19
+ * Цвет адресной строки (meta theme-color) берём из ТОКЕНА темы, а не зашиваем.
20
+ *
21
+ * Здесь стояли литералы `#020817` / `#fff`. При перекраске тема меняется в theme.css, а этот кусок
22
+ * остаётся прежним — и на телефоне поверх новой темы висит адресная строка от старой. Такое не
23
+ * читается как баг: выглядит как «дизайнер так решил».
24
+ */
25
+ useEffect(() => {
26
+ const meta = document.querySelector("meta[name='theme-color']");
27
+ if (!meta) return;
28
+ const background = getComputedStyle(document.documentElement)
29
+ .getPropertyValue("--background")
30
+ .trim();
31
+ if (background) meta.setAttribute("content", background);
32
+ }, [theme]);
33
+
34
+ return (
35
+ <DropdownMenu modal={false}>
36
+ <DropdownMenuTrigger
37
+ render={
38
+ <Button variant="ghost" size="icon" className="scale-95 rounded-full">
39
+ <Sun className="size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
40
+ <Moon className="absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
41
+ <span className="sr-only">{t("theme.toggle")}</span>
42
+ </Button>
43
+ }
44
+ />
45
+ <DropdownMenuContent align="end">
46
+ <DropdownMenuItem onClick={() => setTheme("light")}>
47
+ {t("theme.light")}{" "}
48
+ <Check size={14} className={cn("ms-auto", theme !== "light" && "hidden")} />
49
+ </DropdownMenuItem>
50
+ <DropdownMenuItem onClick={() => setTheme("dark")}>
51
+ {t("theme.dark")}
52
+ <Check size={14} className={cn("ms-auto", theme !== "dark" && "hidden")} />
53
+ </DropdownMenuItem>
54
+ <DropdownMenuItem onClick={() => setTheme("system")}>
55
+ {t("theme.system")}
56
+ <Check size={14} className={cn("ms-auto", theme !== "system" && "hidden")} />
57
+ </DropdownMenuItem>
58
+ </DropdownMenuContent>
59
+ </DropdownMenu>
60
+ );
61
+ }