@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,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Collapsible,
|
|
3
|
+
CollapsibleContent,
|
|
4
|
+
CollapsibleTrigger,
|
|
5
|
+
} from "@icomponents/react/components/ui/collapsible";
|
|
6
|
+
import {
|
|
7
|
+
SidebarGroup,
|
|
8
|
+
SidebarGroupLabel,
|
|
9
|
+
SidebarMenu,
|
|
10
|
+
SidebarMenuButton,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
SidebarMenuSub,
|
|
13
|
+
SidebarMenuSubButton,
|
|
14
|
+
SidebarMenuSubItem,
|
|
15
|
+
useSidebar,
|
|
16
|
+
} from "@icomponents/react/components/ui/sidebar";
|
|
17
|
+
import { Link, useLocation } from "@tanstack/react-router";
|
|
18
|
+
import { ChevronRight } from "lucide-react";
|
|
19
|
+
import { type ReactNode } from "react";
|
|
20
|
+
|
|
21
|
+
import { TESTID, testid } from "../../testids";
|
|
22
|
+
import { Badge } from "../ui/badge";
|
|
23
|
+
import {
|
|
24
|
+
DropdownMenu,
|
|
25
|
+
DropdownMenuContent,
|
|
26
|
+
DropdownMenuItem,
|
|
27
|
+
DropdownMenuLabel,
|
|
28
|
+
DropdownMenuSeparator,
|
|
29
|
+
DropdownMenuTrigger,
|
|
30
|
+
} from "../ui/dropdown-menu";
|
|
31
|
+
import {
|
|
32
|
+
type NavCollapsible,
|
|
33
|
+
type NavItem,
|
|
34
|
+
type NavLink,
|
|
35
|
+
type NavGroup as NavGroupProps,
|
|
36
|
+
} from "./types";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Устойчивая идентичность пункта меню — его АДРЕС, а не подпись.
|
|
40
|
+
*
|
|
41
|
+
* Подпись пункта — это перевод: «Companies» и «Компании» — один и тот же пункт, и e2e, написанный
|
|
42
|
+
* по подписи, краснеет от смены локали или редизайна, ничего не доказав (ровно так протух
|
|
43
|
+
* `platform.e2e.ts`). Адрес же не меняется ни от перевода, ни от вёрстки — и он же отвечает на
|
|
44
|
+
* вопрос, который тест на самом деле задаёт: «виден ли пользователю путь в этот раздел».
|
|
45
|
+
*
|
|
46
|
+
* `/organization-users` → `nav-item-organization-users`, `/` → `nav-item-root`.
|
|
47
|
+
*/
|
|
48
|
+
const navKey = (url: string | undefined): string =>
|
|
49
|
+
(url ?? "").replace(/^\/+/, "").replaceAll("/", "-") || "root";
|
|
50
|
+
|
|
51
|
+
export function NavGroup({ title, items }: NavGroupProps) {
|
|
52
|
+
const { state, isMobile } = useSidebar();
|
|
53
|
+
const href = useLocation({ select: (location) => location.href });
|
|
54
|
+
return (
|
|
55
|
+
<SidebarGroup>
|
|
56
|
+
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
|
57
|
+
<SidebarMenu>
|
|
58
|
+
{items.map((item) => {
|
|
59
|
+
const key = `${item.title}-${item.url}`;
|
|
60
|
+
|
|
61
|
+
if (!item.items) return <SidebarMenuLink key={key} item={item} href={href} />;
|
|
62
|
+
|
|
63
|
+
if (state === "collapsed" && !isMobile)
|
|
64
|
+
return <SidebarMenuCollapsedDropdown key={key} item={item} href={href} />;
|
|
65
|
+
|
|
66
|
+
return <SidebarMenuCollapsible key={key} item={item} href={href} />;
|
|
67
|
+
})}
|
|
68
|
+
</SidebarMenu>
|
|
69
|
+
</SidebarGroup>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function NavBadge({ children }: { children: ReactNode }) {
|
|
74
|
+
return <Badge className="rounded-full px-1 py-0 text-xs">{children}</Badge>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
|
78
|
+
const { setOpenMobile } = useSidebar();
|
|
79
|
+
return (
|
|
80
|
+
<SidebarMenuItem>
|
|
81
|
+
<SidebarMenuButton asChild isActive={checkIsActive(href, item)} tooltip={item.title}>
|
|
82
|
+
<Link
|
|
83
|
+
to={item.url}
|
|
84
|
+
{...testid(TESTID.navItem(navKey(item.url)))}
|
|
85
|
+
onClick={() => setOpenMobile(false)}
|
|
86
|
+
>
|
|
87
|
+
{item.icon && <item.icon />}
|
|
88
|
+
<span>{item.title}</span>
|
|
89
|
+
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
|
90
|
+
</Link>
|
|
91
|
+
</SidebarMenuButton>
|
|
92
|
+
</SidebarMenuItem>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function SidebarMenuCollapsible({ item, href }: { item: NavCollapsible; href: string }) {
|
|
97
|
+
const { setOpenMobile } = useSidebar();
|
|
98
|
+
return (
|
|
99
|
+
<Collapsible
|
|
100
|
+
render={<SidebarMenuItem />}
|
|
101
|
+
defaultOpen={checkIsActive(href, item, true)}
|
|
102
|
+
className="group/collapsible"
|
|
103
|
+
>
|
|
104
|
+
<CollapsibleTrigger render={<SidebarMenuButton tooltip={item.title} />}>
|
|
105
|
+
{item.icon && <item.icon />}
|
|
106
|
+
<span>{item.title}</span>
|
|
107
|
+
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
|
108
|
+
<ChevronRight className="ms-auto transition-transform duration-200 group-data-open/collapsible:rotate-90 rtl:rotate-180" />
|
|
109
|
+
</CollapsibleTrigger>
|
|
110
|
+
<CollapsibleContent className="CollapsibleContent">
|
|
111
|
+
<SidebarMenuSub>
|
|
112
|
+
{item.items.map((subItem) => (
|
|
113
|
+
<SidebarMenuSubItem key={subItem.title}>
|
|
114
|
+
<SidebarMenuSubButton asChild isActive={checkIsActive(href, subItem)}>
|
|
115
|
+
<Link
|
|
116
|
+
to={subItem.url}
|
|
117
|
+
{...testid(TESTID.navItem(navKey(subItem.url)))}
|
|
118
|
+
onClick={() => setOpenMobile(false)}
|
|
119
|
+
>
|
|
120
|
+
{subItem.icon && <subItem.icon />}
|
|
121
|
+
<span>{subItem.title}</span>
|
|
122
|
+
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
|
123
|
+
</Link>
|
|
124
|
+
</SidebarMenuSubButton>
|
|
125
|
+
</SidebarMenuSubItem>
|
|
126
|
+
))}
|
|
127
|
+
</SidebarMenuSub>
|
|
128
|
+
</CollapsibleContent>
|
|
129
|
+
</Collapsible>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function SidebarMenuCollapsedDropdown({ item, href }: { item: NavCollapsible; href: string }) {
|
|
134
|
+
return (
|
|
135
|
+
<SidebarMenuItem>
|
|
136
|
+
<DropdownMenu>
|
|
137
|
+
<DropdownMenuTrigger
|
|
138
|
+
render={
|
|
139
|
+
<SidebarMenuButton
|
|
140
|
+
tooltip={item.title}
|
|
141
|
+
isActive={checkIsActive(href, item)}
|
|
142
|
+
className="group/collapsed-dropdown"
|
|
143
|
+
>
|
|
144
|
+
{item.icon && <item.icon />}
|
|
145
|
+
<span>{item.title}</span>
|
|
146
|
+
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
|
147
|
+
<ChevronRight className="ms-auto transition-transform duration-200 group-data-popup-open/collapsed-dropdown:rotate-90" />
|
|
148
|
+
</SidebarMenuButton>
|
|
149
|
+
}
|
|
150
|
+
/>
|
|
151
|
+
<DropdownMenuContent side="right" align="start" sideOffset={4}>
|
|
152
|
+
<DropdownMenuLabel>
|
|
153
|
+
{item.title} {item.badge ? `(${item.badge})` : ""}
|
|
154
|
+
</DropdownMenuLabel>
|
|
155
|
+
<DropdownMenuSeparator />
|
|
156
|
+
{item.items.map((sub) => (
|
|
157
|
+
<DropdownMenuItem
|
|
158
|
+
key={`${sub.title}-${sub.url}`}
|
|
159
|
+
render={
|
|
160
|
+
<Link
|
|
161
|
+
to={sub.url}
|
|
162
|
+
{...testid(TESTID.navItem(navKey(sub.url)))}
|
|
163
|
+
className={`${checkIsActive(href, sub) ? "bg-secondary" : ""}`}
|
|
164
|
+
>
|
|
165
|
+
{sub.icon && <sub.icon />}
|
|
166
|
+
<span className="max-w-52 text-wrap">{sub.title}</span>
|
|
167
|
+
{sub.badge && <span className="ms-auto text-xs">{sub.badge}</span>}
|
|
168
|
+
</Link>
|
|
169
|
+
}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
</DropdownMenuContent>
|
|
173
|
+
</DropdownMenu>
|
|
174
|
+
</SidebarMenuItem>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function checkIsActive(href: string, item: NavItem, mainNav = false) {
|
|
179
|
+
return (
|
|
180
|
+
href === item.url || // /endpint?search=param
|
|
181
|
+
href.split("?")[0] === item.url || // endpoint
|
|
182
|
+
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
|
|
183
|
+
(mainNav && href.split("/")[1] !== "" && href.split("/")[1] === item?.url?.split("/")[1])
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { SignOutDialog } from "@icomponents/react/components/sign-out-dialog";
|
|
2
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@icomponents/react/components/ui/avatar";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuGroup,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from "@icomponents/react/components/ui/dropdown-menu";
|
|
12
|
+
import {
|
|
13
|
+
SidebarMenu,
|
|
14
|
+
SidebarMenuButton,
|
|
15
|
+
SidebarMenuItem,
|
|
16
|
+
useSidebar,
|
|
17
|
+
} from "@icomponents/react/components/ui/sidebar";
|
|
18
|
+
import useDialogState from "@icomponents/react/hooks/use-dialog-state";
|
|
19
|
+
import { Link, type LinkProps } from "@tanstack/react-router";
|
|
20
|
+
import { ChevronsUpDown, LogOut } from "lucide-react";
|
|
21
|
+
import { type ElementType } from "react";
|
|
22
|
+
|
|
23
|
+
export type NavUserItem = {
|
|
24
|
+
title: string;
|
|
25
|
+
url: LinkProps["to"] | (string & {});
|
|
26
|
+
icon?: ElementType;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type NavUserProps = {
|
|
30
|
+
user: {
|
|
31
|
+
name: string;
|
|
32
|
+
email: string;
|
|
33
|
+
avatar?: string;
|
|
34
|
+
};
|
|
35
|
+
items: NavUserItem[];
|
|
36
|
+
onSignOut: () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function NavUser({ user, items, onSignOut }: NavUserProps) {
|
|
40
|
+
const { isMobile } = useSidebar();
|
|
41
|
+
const [open, setOpen] = useDialogState();
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<SidebarMenu>
|
|
46
|
+
<SidebarMenuItem>
|
|
47
|
+
<DropdownMenu>
|
|
48
|
+
<DropdownMenuTrigger
|
|
49
|
+
render={
|
|
50
|
+
<SidebarMenuButton
|
|
51
|
+
size="lg"
|
|
52
|
+
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
|
53
|
+
>
|
|
54
|
+
<Avatar className="h-8 w-8 rounded-lg">
|
|
55
|
+
<AvatarImage src={user.avatar} alt={user.name} />
|
|
56
|
+
<AvatarFallback className="rounded-lg">SN</AvatarFallback>
|
|
57
|
+
</Avatar>
|
|
58
|
+
<div className="grid flex-1 text-start text-sm leading-tight">
|
|
59
|
+
<span className="truncate font-semibold">{user.name}</span>
|
|
60
|
+
<span className="truncate text-xs">{user.email}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<ChevronsUpDown className="ms-auto size-4" />
|
|
63
|
+
</SidebarMenuButton>
|
|
64
|
+
}
|
|
65
|
+
/>
|
|
66
|
+
<DropdownMenuContent
|
|
67
|
+
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
|
68
|
+
side={isMobile ? "bottom" : "right"}
|
|
69
|
+
align="end"
|
|
70
|
+
sideOffset={4}
|
|
71
|
+
>
|
|
72
|
+
<DropdownMenuLabel className="p-0 font-normal">
|
|
73
|
+
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
|
74
|
+
<Avatar className="h-8 w-8 rounded-lg">
|
|
75
|
+
<AvatarImage src={user.avatar} alt={user.name} />
|
|
76
|
+
<AvatarFallback className="rounded-lg">SN</AvatarFallback>
|
|
77
|
+
</Avatar>
|
|
78
|
+
<div className="grid flex-1 text-start text-sm leading-tight">
|
|
79
|
+
<span className="truncate font-semibold">{user.name}</span>
|
|
80
|
+
<span className="truncate text-xs">{user.email}</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</DropdownMenuLabel>
|
|
84
|
+
<DropdownMenuSeparator />
|
|
85
|
+
<DropdownMenuGroup>
|
|
86
|
+
{items.map((item) => (
|
|
87
|
+
<DropdownMenuItem
|
|
88
|
+
key={item.title}
|
|
89
|
+
render={
|
|
90
|
+
<Link to={item.url}>
|
|
91
|
+
{item.icon && <item.icon />}
|
|
92
|
+
{item.title}
|
|
93
|
+
</Link>
|
|
94
|
+
}
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
</DropdownMenuGroup>
|
|
98
|
+
<DropdownMenuSeparator />
|
|
99
|
+
<DropdownMenuItem
|
|
100
|
+
data-testid="sign-out"
|
|
101
|
+
variant="destructive"
|
|
102
|
+
onClick={() => setOpen(true)}
|
|
103
|
+
>
|
|
104
|
+
<LogOut />
|
|
105
|
+
Sign out
|
|
106
|
+
</DropdownMenuItem>
|
|
107
|
+
</DropdownMenuContent>
|
|
108
|
+
</DropdownMenu>
|
|
109
|
+
</SidebarMenuItem>
|
|
110
|
+
</SidebarMenu>
|
|
111
|
+
|
|
112
|
+
<SignOutDialog open={!!open} onOpenChange={setOpen} onSignOut={onSignOut} />
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
2
|
+
import { TESTID, testid } from "@icomponents/react/testids";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Шапка экрана — одна на всю консоль.
|
|
8
|
+
*
|
|
9
|
+
* Все экраны прототипа (`prototype/*.html`) несут `data-component="page-header"` ОДНОЙ структуры:
|
|
10
|
+
* моно-kicker с коротким штрихом бренда → `h1` display-шрифтом → абзац-объяснение → капсулы-бейджи,
|
|
11
|
+
* а справа — пара со-первичных действий. Это замер по семи экранам, а не догадка, поэтому шапка
|
|
12
|
+
* живёт компонентом, а не копией в каждом экране.
|
|
13
|
+
*
|
|
14
|
+
* Почему это важнее экономии строк: здесь навешен `TESTID.screenTitle`. Пока заголовок собирал
|
|
15
|
+
* каждый экран сам, контракт вёрстки держался на памяти автора — а забытый `data-testid` не роняет
|
|
16
|
+
* ни линтер, ни тайпчек, он тихо выключает e2e, который этот экран проверял.
|
|
17
|
+
*
|
|
18
|
+
* `description` и `actions` — `ReactNode`: в описании стоят моноширинные технические литералы
|
|
19
|
+
* (`draft → sandbox → active`, `409`), которые НЕ переводятся и собираются вызывающим экраном.
|
|
20
|
+
*/
|
|
21
|
+
const screenBadgeVariants = cva(
|
|
22
|
+
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[12px] whitespace-nowrap",
|
|
23
|
+
{
|
|
24
|
+
variants: {
|
|
25
|
+
tone: {
|
|
26
|
+
primary: "border-primary/40 bg-primary/10 font-medium text-primary",
|
|
27
|
+
neutral: "border-border bg-card text-muted-foreground",
|
|
28
|
+
success: "border-success/40 bg-success/10 font-semibold text-success",
|
|
29
|
+
destructive: "border-destructive/40 bg-destructive/10 font-semibold text-destructive",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { tone: "neutral" },
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export type ScreenBadgeTone = NonNullable<VariantProps<typeof screenBadgeVariants>["tone"]>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Капсула под заголовком: область действия права, режим, состояние стенда.
|
|
40
|
+
*
|
|
41
|
+
* Иконка типизирована структурно (`ComponentType<{ className }>`), а не импортом из набора: ядро
|
|
42
|
+
* не должно тащить зависимость на конкретную библиотеку иконок ради подписи в шапке.
|
|
43
|
+
*/
|
|
44
|
+
export function ScreenBadge({
|
|
45
|
+
tone,
|
|
46
|
+
icon: Icon,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
...props
|
|
50
|
+
}: React.ComponentProps<"span"> &
|
|
51
|
+
VariantProps<typeof screenBadgeVariants> & {
|
|
52
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<span className={cn(screenBadgeVariants({ tone }), className)} {...props}>
|
|
56
|
+
{Icon ? <Icon className="size-3.5 shrink-0" /> : null}
|
|
57
|
+
{children}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ScreenHeaderProps = {
|
|
63
|
+
/** Моно-капс над заголовком (`platform · control plane`). Латиница-термин — НЕ переводится. */
|
|
64
|
+
kicker?: string;
|
|
65
|
+
/** `h1` экрана. Несёт `TESTID.screenTitle` — отдельно вешать не нужно и не надо. */
|
|
66
|
+
title: React.ReactNode;
|
|
67
|
+
/** Абзац-объяснение. `ReactNode`, потому что внутри моноширинные литералы. */
|
|
68
|
+
description?: React.ReactNode;
|
|
69
|
+
/** Ряд капсул (`ScreenBadge`). */
|
|
70
|
+
badges?: React.ReactNode;
|
|
71
|
+
/** Со-первичные действия справа. */
|
|
72
|
+
actions?: React.ReactNode;
|
|
73
|
+
className?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function ScreenHeader({
|
|
77
|
+
kicker,
|
|
78
|
+
title,
|
|
79
|
+
description,
|
|
80
|
+
badges,
|
|
81
|
+
actions,
|
|
82
|
+
className,
|
|
83
|
+
}: ScreenHeaderProps) {
|
|
84
|
+
return (
|
|
85
|
+
<section
|
|
86
|
+
className={cn("flex flex-wrap items-end justify-between gap-x-6 gap-y-4", className)}
|
|
87
|
+
data-slot="screen-header"
|
|
88
|
+
>
|
|
89
|
+
<div className="min-w-0">
|
|
90
|
+
{kicker ? (
|
|
91
|
+
<div className="flex items-center gap-2 font-mono text-[11px] font-medium tracking-[0.14em] text-muted-foreground uppercase">
|
|
92
|
+
<span className="h-px w-6 bg-primary" aria-hidden />
|
|
93
|
+
{kicker}
|
|
94
|
+
</div>
|
|
95
|
+
) : null}
|
|
96
|
+
|
|
97
|
+
<h1
|
|
98
|
+
{...testid(TESTID.screenTitle)}
|
|
99
|
+
className={cn(
|
|
100
|
+
"font-display text-[32px] leading-[1.1] font-bold tracking-tight",
|
|
101
|
+
kicker && "mt-1.5"
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{title}
|
|
105
|
+
</h1>
|
|
106
|
+
|
|
107
|
+
{description ? (
|
|
108
|
+
<p className="mt-2 max-w-[64ch] text-[13px] leading-snug text-muted-foreground">
|
|
109
|
+
{description}
|
|
110
|
+
</p>
|
|
111
|
+
) : null}
|
|
112
|
+
|
|
113
|
+
{badges ? <div className="mt-3 flex flex-wrap items-center gap-2">{badges}</div> : null}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
|
117
|
+
</section>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DropdownMenu,
|
|
3
|
+
DropdownMenuContent,
|
|
4
|
+
DropdownMenuItem,
|
|
5
|
+
DropdownMenuLabel,
|
|
6
|
+
DropdownMenuSeparator,
|
|
7
|
+
DropdownMenuShortcut,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@icomponents/react/components/ui/dropdown-menu";
|
|
10
|
+
import {
|
|
11
|
+
SidebarMenu,
|
|
12
|
+
SidebarMenuButton,
|
|
13
|
+
SidebarMenuItem,
|
|
14
|
+
useSidebar,
|
|
15
|
+
} from "@icomponents/react/components/ui/sidebar";
|
|
16
|
+
import { ChevronsUpDown, Plus } from "lucide-react";
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
|
|
19
|
+
type TeamSwitcherProps = {
|
|
20
|
+
teams: {
|
|
21
|
+
name: string;
|
|
22
|
+
logo: React.ElementType;
|
|
23
|
+
plan: string;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function TeamSwitcher({ teams }: TeamSwitcherProps) {
|
|
28
|
+
const { isMobile } = useSidebar();
|
|
29
|
+
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<SidebarMenu>
|
|
33
|
+
<SidebarMenuItem>
|
|
34
|
+
<DropdownMenu>
|
|
35
|
+
<DropdownMenuTrigger
|
|
36
|
+
render={
|
|
37
|
+
<SidebarMenuButton
|
|
38
|
+
size="lg"
|
|
39
|
+
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
|
40
|
+
>
|
|
41
|
+
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
42
|
+
<activeTeam.logo className="size-4" />
|
|
43
|
+
</div>
|
|
44
|
+
<div className="grid flex-1 text-start text-sm leading-tight">
|
|
45
|
+
<span className="truncate font-semibold">{activeTeam.name}</span>
|
|
46
|
+
<span className="truncate text-xs">{activeTeam.plan}</span>
|
|
47
|
+
</div>
|
|
48
|
+
<ChevronsUpDown className="ms-auto" />
|
|
49
|
+
</SidebarMenuButton>
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
<DropdownMenuContent
|
|
53
|
+
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
|
54
|
+
align="start"
|
|
55
|
+
side={isMobile ? "bottom" : "right"}
|
|
56
|
+
sideOffset={4}
|
|
57
|
+
>
|
|
58
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground">Teams</DropdownMenuLabel>
|
|
59
|
+
{teams.map((team, index) => (
|
|
60
|
+
<DropdownMenuItem
|
|
61
|
+
key={team.name}
|
|
62
|
+
onClick={() => setActiveTeam(team)}
|
|
63
|
+
className="gap-2 p-2"
|
|
64
|
+
>
|
|
65
|
+
<div className="flex size-6 items-center justify-center rounded-sm border">
|
|
66
|
+
<team.logo className="size-4 shrink-0" />
|
|
67
|
+
</div>
|
|
68
|
+
{team.name}
|
|
69
|
+
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
|
70
|
+
</DropdownMenuItem>
|
|
71
|
+
))}
|
|
72
|
+
<DropdownMenuSeparator />
|
|
73
|
+
<DropdownMenuItem className="gap-2 p-2">
|
|
74
|
+
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
|
75
|
+
<Plus className="size-4" />
|
|
76
|
+
</div>
|
|
77
|
+
<div className="font-medium text-muted-foreground">Add team</div>
|
|
78
|
+
</DropdownMenuItem>
|
|
79
|
+
</DropdownMenuContent>
|
|
80
|
+
</DropdownMenu>
|
|
81
|
+
</SidebarMenuItem>
|
|
82
|
+
</SidebarMenu>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 { cn } from "@icomponents/react/lib/utils";
|
|
9
|
+
import { Link } from "@tanstack/react-router";
|
|
10
|
+
import { Menu } from "lucide-react";
|
|
11
|
+
|
|
12
|
+
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
|
|
13
|
+
links: {
|
|
14
|
+
title: string;
|
|
15
|
+
href: string;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function TopNav({ className, links, ...props }: TopNavProps) {
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
<DropdownMenu modal={false}>
|
|
25
|
+
<DropdownMenuTrigger
|
|
26
|
+
render={
|
|
27
|
+
<Button size="icon" variant="outline" className={cn("md:size-7 lg:hidden", className)}>
|
|
28
|
+
<Menu />
|
|
29
|
+
<span className="sr-only">Toggle navigation menu</span>
|
|
30
|
+
</Button>
|
|
31
|
+
}
|
|
32
|
+
/>
|
|
33
|
+
<DropdownMenuContent side="bottom" align="start">
|
|
34
|
+
{links.map(({ title, href, isActive, disabled }) => (
|
|
35
|
+
<DropdownMenuItem
|
|
36
|
+
key={`${title}-${href}`}
|
|
37
|
+
render={
|
|
38
|
+
<Link
|
|
39
|
+
to={href}
|
|
40
|
+
className={!isActive ? "text-muted-foreground" : ""}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
>
|
|
43
|
+
{title}
|
|
44
|
+
</Link>
|
|
45
|
+
}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</DropdownMenuContent>
|
|
49
|
+
</DropdownMenu>
|
|
50
|
+
|
|
51
|
+
<nav
|
|
52
|
+
className={cn("hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6", className)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{links.map(({ title, href, isActive, disabled }) => (
|
|
56
|
+
<Link
|
|
57
|
+
key={`${title}-${href}`}
|
|
58
|
+
to={href}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
className={`text-sm font-medium transition-colors hover:text-primary ${isActive ? "" : "text-muted-foreground"}`}
|
|
61
|
+
>
|
|
62
|
+
{title}
|
|
63
|
+
</Link>
|
|
64
|
+
))}
|
|
65
|
+
</nav>
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type LinkProps } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
type User = {
|
|
4
|
+
name: string;
|
|
5
|
+
email: string;
|
|
6
|
+
avatar: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type Team = {
|
|
10
|
+
name: string;
|
|
11
|
+
logo: React.ElementType;
|
|
12
|
+
plan: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type BaseNavItem = {
|
|
16
|
+
title: string;
|
|
17
|
+
badge?: string;
|
|
18
|
+
icon?: React.ElementType;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type NavLink = BaseNavItem & {
|
|
22
|
+
url: LinkProps["to"] | (string & {});
|
|
23
|
+
items?: never;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type NavCollapsible = BaseNavItem & {
|
|
27
|
+
items: (BaseNavItem & { url: LinkProps["to"] | (string & {}) })[];
|
|
28
|
+
url?: never;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type NavItem = NavCollapsible | NavLink;
|
|
32
|
+
|
|
33
|
+
type NavGroup = {
|
|
34
|
+
title: string;
|
|
35
|
+
items: NavItem[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type SidebarData = {
|
|
39
|
+
user: User;
|
|
40
|
+
teams: Team[];
|
|
41
|
+
navGroups: NavGroup[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
2
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@icomponents/react/components/ui/popover";
|
|
3
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
4
|
+
import { CircleHelp } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
type LearnMoreProps = Omit<React.ComponentProps<typeof Popover>, "children"> & {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
contentProps?: React.ComponentProps<typeof PopoverContent>;
|
|
9
|
+
triggerProps?: React.ComponentProps<typeof PopoverTrigger>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function LearnMore({ children, contentProps, triggerProps, ...props }: LearnMoreProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Popover {...props}>
|
|
15
|
+
<PopoverTrigger
|
|
16
|
+
{...triggerProps}
|
|
17
|
+
className={cn("size-5 rounded-full", triggerProps?.className)}
|
|
18
|
+
render={
|
|
19
|
+
<Button variant="outline" size="icon">
|
|
20
|
+
<span className="sr-only">Learn more</span>
|
|
21
|
+
<CircleHelp className="size-4 [&>circle]:hidden" />
|
|
22
|
+
</Button>
|
|
23
|
+
}
|
|
24
|
+
/>
|
|
25
|
+
<PopoverContent
|
|
26
|
+
side="top"
|
|
27
|
+
align="start"
|
|
28
|
+
{...contentProps}
|
|
29
|
+
className={cn("text-sm text-muted-foreground", contentProps?.className)}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</PopoverContent>
|
|
33
|
+
</Popover>
|
|
34
|
+
);
|
|
35
|
+
}
|