@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.
- package/README.md +40 -0
- package/package.json +165 -0
- package/src/assets/custom/icon-dir.tsx +105 -0
- package/src/assets/custom/icon-layout-compact.tsx +84 -0
- package/src/assets/custom/icon-layout-default.tsx +48 -0
- package/src/assets/custom/icon-layout-full.tsx +68 -0
- package/src/assets/custom/icon-sidebar-floating.tsx +62 -0
- package/src/assets/custom/icon-sidebar-inset.tsx +53 -0
- package/src/assets/custom/icon-sidebar-sidebar.tsx +48 -0
- package/src/assets/custom/icon-theme-dark.tsx +36 -0
- package/src/assets/custom/icon-theme-light.tsx +35 -0
- package/src/assets/custom/icon-theme-system.tsx +113 -0
- package/src/components/admin-page-header.tsx +29 -0
- package/src/components/color-theme-switch.tsx +93 -0
- package/src/components/coming-soon.tsx +16 -0
- package/src/components/command-menu.tsx +98 -0
- package/src/components/config-drawer.tsx +401 -0
- package/src/components/confirm-dialog.dom.test.tsx +60 -0
- package/src/components/confirm-dialog.tsx +75 -0
- package/src/components/data-table/bulk-actions.tsx +193 -0
- package/src/components/data-table/column-header.tsx +66 -0
- package/src/components/data-table/data-table.dom.test.tsx +54 -0
- package/src/components/data-table/data-table.tsx +189 -0
- package/src/components/data-table/faceted-filter.tsx +156 -0
- package/src/components/data-table/index.ts +5 -0
- package/src/components/data-table/pagination.tsx +127 -0
- package/src/components/data-table/tanstack-table.d.ts +12 -0
- package/src/components/data-table/toolbar.tsx +88 -0
- package/src/components/data-table/view-options.tsx +49 -0
- package/src/components/date-picker.tsx +39 -0
- package/src/components/detail-card.tsx +63 -0
- package/src/components/language-switch.tsx +38 -0
- package/src/components/layout/app-sidebar.tsx +59 -0
- package/src/components/layout/app-title.tsx +62 -0
- package/src/components/layout/header.tsx +61 -0
- package/src/components/layout/main.tsx +34 -0
- package/src/components/layout/nav-group.tsx +185 -0
- package/src/components/layout/nav-user.tsx +115 -0
- package/src/components/layout/screen-header.tsx +119 -0
- package/src/components/layout/team-switcher.tsx +84 -0
- package/src/components/layout/top-nav.tsx +68 -0
- package/src/components/layout/types.ts +44 -0
- package/src/components/learn-more.tsx +35 -0
- package/src/components/long-text.tsx +80 -0
- package/src/components/navigation-progress.tsx +18 -0
- package/src/components/password-input.tsx +36 -0
- package/src/components/profile-dropdown.tsx +106 -0
- package/src/components/search.tsx +38 -0
- package/src/components/select-dropdown.tsx +62 -0
- package/src/components/sign-out-dialog.tsx +31 -0
- package/src/components/skip-to-main.tsx +10 -0
- package/src/components/stat-tile.tsx +56 -0
- package/src/components/status-badge.test.ts +23 -0
- package/src/components/status-badge.tsx +49 -0
- package/src/components/theme-switch.tsx +61 -0
- package/src/components/ui/alert-dialog.tsx +134 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/avatar.tsx +38 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/base-ui-state-attrs.dom.test.tsx +80 -0
- package/src/components/ui/button.tsx +51 -0
- package/src/components/ui/calendar.tsx +174 -0
- package/src/components/ui/card.tsx +74 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +18 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +129 -0
- package/src/components/ui/dropdown-menu.tsx +254 -0
- package/src/components/ui/form.tsx +155 -0
- package/src/components/ui/input-otp.tsx +67 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +54 -0
- package/src/components/ui/radio-group.tsx +37 -0
- package/src/components/ui/scroll-area.tsx +57 -0
- package/src/components/ui/select.tsx +213 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +128 -0
- package/src/components/ui/sidebar.tsx +702 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +26 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/components/ui/table.tsx +89 -0
- package/src/components/ui/tabs.tsx +51 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/config/fonts.ts +2 -0
- package/src/context/direction-provider.tsx +61 -0
- package/src/context/font-provider.tsx +70 -0
- package/src/context/language-provider.tsx +73 -0
- package/src/context/layout-provider.tsx +81 -0
- package/src/context/search-provider.tsx +48 -0
- package/src/context/theme-provider.tsx +108 -0
- package/src/hooks/sort-url.test.ts +16 -0
- package/src/hooks/use-dialog-state.tsx +15 -0
- package/src/hooks/use-mobile.tsx +16 -0
- package/src/hooks/use-table-url-state.ts +243 -0
- package/src/i18n/common.ts +164 -0
- package/src/i18n/create-i18n.test.ts +50 -0
- package/src/i18n/create-i18n.ts +67 -0
- package/src/i18n/types.ts +22 -0
- package/src/i18n/use-translation.ts +6 -0
- package/src/lib/clipboard.test.ts +46 -0
- package/src/lib/clipboard.ts +29 -0
- package/src/lib/cookies.ts +39 -0
- package/src/lib/format-cell.test.ts +57 -0
- package/src/lib/format-cell.ts +63 -0
- package/src/lib/handle-server-error.test.ts +85 -0
- package/src/lib/handle-server-error.ts +39 -0
- package/src/lib/random-id.ts +13 -0
- package/src/lib/show-submitted-data.tsx +14 -0
- package/src/lib/utils.test.ts +35 -0
- package/src/lib/utils.ts +78 -0
- package/src/secure-context.contract.test.ts +63 -0
- package/src/testids.contract.test.ts +113 -0
- package/src/testids.ts +8 -0
- 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
|
+
}
|