@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,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronLeftIcon,
|
|
3
|
+
ChevronRightIcon,
|
|
4
|
+
DoubleArrowLeftIcon,
|
|
5
|
+
DoubleArrowRightIcon,
|
|
6
|
+
} from "@radix-ui/react-icons";
|
|
7
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@icomponents/react/components/ui/select";
|
|
15
|
+
import { cn, getPageNumbers } from "@icomponents/react/lib/utils";
|
|
16
|
+
import { type Table } from "@tanstack/react-table";
|
|
17
|
+
|
|
18
|
+
type DataTablePaginationProps<TData> = {
|
|
19
|
+
table: Table<TData>;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) {
|
|
24
|
+
const currentPage = table.getState().pagination.pageIndex + 1;
|
|
25
|
+
const totalPages = table.getPageCount();
|
|
26
|
+
const pageNumbers = getPageNumbers(currentPage, totalPages);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex items-center justify-between overflow-clip px-2",
|
|
32
|
+
"@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4",
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
style={{ overflowClipMargin: 1 }}
|
|
36
|
+
>
|
|
37
|
+
<div className="flex w-full items-center justify-between">
|
|
38
|
+
<div className="flex w-25 items-center justify-center text-sm font-medium @2xl/content:hidden">
|
|
39
|
+
Page {currentPage} of {totalPages}
|
|
40
|
+
</div>
|
|
41
|
+
<div className="flex items-center gap-2 @max-2xl/content:flex-row-reverse">
|
|
42
|
+
<Select
|
|
43
|
+
value={`${table.getState().pagination.pageSize}`}
|
|
44
|
+
onValueChange={(value) => {
|
|
45
|
+
table.setPageSize(Number(value));
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<SelectTrigger className="h-8 w-17.5">
|
|
49
|
+
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
|
50
|
+
</SelectTrigger>
|
|
51
|
+
<SelectContent side="top">
|
|
52
|
+
{[10, 20, 30, 40, 50].map((pageSize) => (
|
|
53
|
+
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
54
|
+
{pageSize}
|
|
55
|
+
</SelectItem>
|
|
56
|
+
))}
|
|
57
|
+
</SelectContent>
|
|
58
|
+
</Select>
|
|
59
|
+
<p className="hidden text-sm font-medium sm:block">Rows per page</p>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="flex items-center sm:space-x-6 lg:space-x-8">
|
|
64
|
+
<div className="flex w-25 items-center justify-center text-sm font-medium @max-3xl/content:hidden">
|
|
65
|
+
Page {currentPage} of {totalPages}
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex items-center space-x-2">
|
|
68
|
+
<Button
|
|
69
|
+
variant="outline"
|
|
70
|
+
className="size-8 p-0 @max-md/content:hidden"
|
|
71
|
+
onClick={() => table.setPageIndex(0)}
|
|
72
|
+
disabled={!table.getCanPreviousPage()}
|
|
73
|
+
>
|
|
74
|
+
<span className="sr-only">Go to first page</span>
|
|
75
|
+
<DoubleArrowLeftIcon className="h-4 w-4" />
|
|
76
|
+
</Button>
|
|
77
|
+
<Button
|
|
78
|
+
variant="outline"
|
|
79
|
+
className="size-8 p-0"
|
|
80
|
+
onClick={() => table.previousPage()}
|
|
81
|
+
disabled={!table.getCanPreviousPage()}
|
|
82
|
+
>
|
|
83
|
+
<span className="sr-only">Go to previous page</span>
|
|
84
|
+
<ChevronLeftIcon className="h-4 w-4" />
|
|
85
|
+
</Button>
|
|
86
|
+
|
|
87
|
+
{/* Page number buttons */}
|
|
88
|
+
{pageNumbers.map((pageNumber, index) => (
|
|
89
|
+
<div key={`${pageNumber}-${index}`} className="flex items-center">
|
|
90
|
+
{pageNumber === "..." ? (
|
|
91
|
+
<span className="px-1 text-sm text-muted-foreground">...</span>
|
|
92
|
+
) : (
|
|
93
|
+
<Button
|
|
94
|
+
variant={currentPage === pageNumber ? "default" : "outline"}
|
|
95
|
+
className="h-8 min-w-8 px-2"
|
|
96
|
+
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
|
97
|
+
>
|
|
98
|
+
<span className="sr-only">Go to page {pageNumber}</span>
|
|
99
|
+
{pageNumber}
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
|
|
105
|
+
<Button
|
|
106
|
+
variant="outline"
|
|
107
|
+
className="size-8 p-0"
|
|
108
|
+
onClick={() => table.nextPage()}
|
|
109
|
+
disabled={!table.getCanNextPage()}
|
|
110
|
+
>
|
|
111
|
+
<span className="sr-only">Go to next page</span>
|
|
112
|
+
<ChevronRightIcon className="h-4 w-4" />
|
|
113
|
+
</Button>
|
|
114
|
+
<Button
|
|
115
|
+
variant="outline"
|
|
116
|
+
className="size-8 p-0 @max-md/content:hidden"
|
|
117
|
+
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
118
|
+
disabled={!table.getCanNextPage()}
|
|
119
|
+
>
|
|
120
|
+
<span className="sr-only">Go to last page</span>
|
|
121
|
+
<DoubleArrowRightIcon className="h-4 w-4" />
|
|
122
|
+
</Button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "@tanstack/react-table";
|
|
2
|
+
|
|
3
|
+
// Module augmentation for the shared data-table pattern (mirrors the per-slice copies in
|
|
4
|
+
// packages/iam/src/features/*/tanstack-table.d.ts). Identical augmentations merge without conflict.
|
|
5
|
+
declare module "@tanstack/react-table" {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
interface ColumnMeta<TData, TValue> {
|
|
8
|
+
className?: string; // apply to both th and td
|
|
9
|
+
tdClassName?: string;
|
|
10
|
+
thClassName?: string;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Cross2Icon } from "@radix-ui/react-icons";
|
|
2
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
3
|
+
import { Input } from "@icomponents/react/components/ui/input";
|
|
4
|
+
import { type Table } from "@tanstack/react-table";
|
|
5
|
+
|
|
6
|
+
import { DataTableFacetedFilter } from "./faceted-filter";
|
|
7
|
+
import { DataTableViewOptions } from "./view-options";
|
|
8
|
+
|
|
9
|
+
type DataTableToolbarProps<TData> = {
|
|
10
|
+
table: Table<TData>;
|
|
11
|
+
searchPlaceholder?: string;
|
|
12
|
+
searchKey?: string;
|
|
13
|
+
filters?: {
|
|
14
|
+
columnId: string;
|
|
15
|
+
title: string;
|
|
16
|
+
options: {
|
|
17
|
+
label: string;
|
|
18
|
+
value: string;
|
|
19
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
20
|
+
}[];
|
|
21
|
+
testId?: string;
|
|
22
|
+
single?: boolean;
|
|
23
|
+
}[];
|
|
24
|
+
extra?: React.ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function DataTableToolbar<TData>({
|
|
28
|
+
table,
|
|
29
|
+
searchPlaceholder = "Filter...",
|
|
30
|
+
searchKey,
|
|
31
|
+
filters = [],
|
|
32
|
+
extra,
|
|
33
|
+
}: DataTableToolbarProps<TData>) {
|
|
34
|
+
const isFiltered = table.getState().columnFilters.length > 0 || table.getState().globalFilter;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex items-center justify-between">
|
|
38
|
+
<div className="flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2">
|
|
39
|
+
{searchKey ? (
|
|
40
|
+
<Input
|
|
41
|
+
placeholder={searchPlaceholder}
|
|
42
|
+
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
|
43
|
+
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value)}
|
|
44
|
+
className="h-8 w-37.5 lg:w-62.5"
|
|
45
|
+
/>
|
|
46
|
+
) : (
|
|
47
|
+
<Input
|
|
48
|
+
placeholder={searchPlaceholder}
|
|
49
|
+
value={table.getState().globalFilter ?? ""}
|
|
50
|
+
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
|
51
|
+
className="h-8 w-37.5 lg:w-62.5"
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
<div className="flex gap-x-2">
|
|
55
|
+
{filters.map((filter) => {
|
|
56
|
+
const column = table.getColumn(filter.columnId);
|
|
57
|
+
if (!column) return null;
|
|
58
|
+
return (
|
|
59
|
+
<DataTableFacetedFilter
|
|
60
|
+
key={filter.columnId}
|
|
61
|
+
column={column}
|
|
62
|
+
title={filter.title}
|
|
63
|
+
options={filter.options}
|
|
64
|
+
testId={filter.testId}
|
|
65
|
+
single={filter.single}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
{extra}
|
|
71
|
+
{isFiltered && (
|
|
72
|
+
<Button
|
|
73
|
+
variant="ghost"
|
|
74
|
+
onClick={() => {
|
|
75
|
+
table.resetColumnFilters();
|
|
76
|
+
table.setGlobalFilter("");
|
|
77
|
+
}}
|
|
78
|
+
className="h-8 px-2 lg:px-3"
|
|
79
|
+
>
|
|
80
|
+
Reset
|
|
81
|
+
<Cross2Icon className="ms-2 h-4 w-4" />
|
|
82
|
+
</Button>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
<DataTableViewOptions table={table} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { MixerHorizontalIcon } from "@radix-ui/react-icons";
|
|
2
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuCheckboxItem,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuLabel,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@icomponents/react/components/ui/dropdown-menu";
|
|
11
|
+
import { type Table } from "@tanstack/react-table";
|
|
12
|
+
|
|
13
|
+
type DataTableViewOptionsProps<TData> = {
|
|
14
|
+
table: Table<TData>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
|
|
18
|
+
return (
|
|
19
|
+
<DropdownMenu modal={false}>
|
|
20
|
+
<DropdownMenuTrigger
|
|
21
|
+
render={
|
|
22
|
+
<Button variant="outline" size="sm" className="ms-auto hidden h-8 lg:flex">
|
|
23
|
+
<MixerHorizontalIcon className="size-4" />
|
|
24
|
+
View
|
|
25
|
+
</Button>
|
|
26
|
+
}
|
|
27
|
+
/>
|
|
28
|
+
<DropdownMenuContent align="end" className="w-37.5">
|
|
29
|
+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
|
30
|
+
<DropdownMenuSeparator />
|
|
31
|
+
{table
|
|
32
|
+
.getAllColumns()
|
|
33
|
+
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
|
|
34
|
+
.map((column) => {
|
|
35
|
+
return (
|
|
36
|
+
<DropdownMenuCheckboxItem
|
|
37
|
+
key={column.id}
|
|
38
|
+
className="capitalize"
|
|
39
|
+
checked={column.getIsVisible()}
|
|
40
|
+
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
41
|
+
>
|
|
42
|
+
{column.id}
|
|
43
|
+
</DropdownMenuCheckboxItem>
|
|
44
|
+
);
|
|
45
|
+
})}
|
|
46
|
+
</DropdownMenuContent>
|
|
47
|
+
</DropdownMenu>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
2
|
+
import { Calendar } from "@icomponents/react/components/ui/calendar";
|
|
3
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@icomponents/react/components/ui/popover";
|
|
4
|
+
import { format } from "date-fns";
|
|
5
|
+
import { Calendar as CalendarIcon } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
type DatePickerProps = {
|
|
8
|
+
selected: Date | undefined;
|
|
9
|
+
onSelect: (date: Date | undefined) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function DatePicker({ selected, onSelect, placeholder = "Pick a date" }: DatePickerProps) {
|
|
14
|
+
return (
|
|
15
|
+
<Popover>
|
|
16
|
+
<PopoverTrigger
|
|
17
|
+
render={
|
|
18
|
+
<Button
|
|
19
|
+
variant="outline"
|
|
20
|
+
data-empty={!selected}
|
|
21
|
+
className="w-60 justify-start text-start font-normal data-[empty=true]:text-muted-foreground"
|
|
22
|
+
>
|
|
23
|
+
{selected ? format(selected, "MMM d, yyyy") : <span>{placeholder}</span>}
|
|
24
|
+
<CalendarIcon className="ms-auto h-4 w-4 opacity-50" />
|
|
25
|
+
</Button>
|
|
26
|
+
}
|
|
27
|
+
/>
|
|
28
|
+
<PopoverContent className="w-auto p-0">
|
|
29
|
+
<Calendar
|
|
30
|
+
mode="single"
|
|
31
|
+
captionLayout="dropdown"
|
|
32
|
+
selected={selected}
|
|
33
|
+
onSelect={onSelect}
|
|
34
|
+
disabled={(date: Date) => date > new Date() || date < new Date("1900-01-01")}
|
|
35
|
+
/>
|
|
36
|
+
</PopoverContent>
|
|
37
|
+
</Popover>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
// Section card in the Evoca design language: a white surface with a hairline border, 4px radius and
|
|
5
|
+
// a soft layered shadow, opened by a fixed-height header row (condensed display title + optional
|
|
6
|
+
// right-aligned action). Reused across company detail / registry / dashboard so every panel reads
|
|
7
|
+
// as one consistent, richer card system (vs a flat bordered box).
|
|
8
|
+
export function DetailCard({
|
|
9
|
+
title,
|
|
10
|
+
action,
|
|
11
|
+
bodyClassName,
|
|
12
|
+
className,
|
|
13
|
+
children,
|
|
14
|
+
...props
|
|
15
|
+
}: React.ComponentProps<"section"> & {
|
|
16
|
+
title: React.ReactNode;
|
|
17
|
+
action?: React.ReactNode;
|
|
18
|
+
bodyClassName?: string;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<section
|
|
22
|
+
data-component="detail-card"
|
|
23
|
+
className={cn("overflow-hidden rounded border border-border bg-card soft-shadow", className)}
|
|
24
|
+
{...props}
|
|
25
|
+
>
|
|
26
|
+
<div className="flex h-14 items-center justify-between gap-3 border-b border-border px-5">
|
|
27
|
+
<h2 className="font-display text-[20px] font-bold leading-none tracking-tight text-foreground">
|
|
28
|
+
{title}
|
|
29
|
+
</h2>
|
|
30
|
+
{action}
|
|
31
|
+
</div>
|
|
32
|
+
<div className={bodyClassName}>{children}</div>
|
|
33
|
+
</section>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One definition-list row inside a DetailCard body (wrap rows in a <dl className="divide-y
|
|
38
|
+
// divide-border">). Label is muted + fixed-width; value is foreground, monospaced+tabular for
|
|
39
|
+
// numeric/identifier data.
|
|
40
|
+
export function DetailRow({
|
|
41
|
+
label,
|
|
42
|
+
value,
|
|
43
|
+
mono,
|
|
44
|
+
}: {
|
|
45
|
+
label: React.ReactNode;
|
|
46
|
+
value: React.ReactNode;
|
|
47
|
+
mono?: boolean;
|
|
48
|
+
}) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-start gap-4 px-5 py-3">
|
|
51
|
+
<dt className="w-44 shrink-0 text-[13px] text-muted-foreground">{label}</dt>
|
|
52
|
+
<dd
|
|
53
|
+
className={
|
|
54
|
+
mono
|
|
55
|
+
? "font-mono text-[13.5px] tabular-nums text-foreground"
|
|
56
|
+
: "text-[14px] font-medium text-foreground"
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
{value}
|
|
60
|
+
</dd>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { useLanguage } from "@icomponents/react/context/language-provider";
|
|
9
|
+
import type { Locale } from "@icomponents/react/i18n/types";
|
|
10
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
11
|
+
import { Check, Languages } from "lucide-react";
|
|
12
|
+
|
|
13
|
+
const LABEL: Record<Locale, string> = { ru: "Русский", en: "English" };
|
|
14
|
+
|
|
15
|
+
/** Компактный переключатель языка для шапки (рядом с ThemeSwitch). Меняет i18next + куку + <html lang>. */
|
|
16
|
+
export function LanguageSwitch() {
|
|
17
|
+
const { lang, langs, setLang } = useLanguage();
|
|
18
|
+
return (
|
|
19
|
+
<DropdownMenu modal={false}>
|
|
20
|
+
<DropdownMenuTrigger
|
|
21
|
+
render={
|
|
22
|
+
<Button variant="ghost" size="icon" className="scale-95 rounded-full">
|
|
23
|
+
<Languages className="size-[1.2rem]" />
|
|
24
|
+
<span className="sr-only">Сменить язык / Switch language</span>
|
|
25
|
+
</Button>
|
|
26
|
+
}
|
|
27
|
+
/>
|
|
28
|
+
<DropdownMenuContent align="end">
|
|
29
|
+
{langs.map((l) => (
|
|
30
|
+
<DropdownMenuItem key={l} onClick={() => setLang(l)}>
|
|
31
|
+
{LABEL[l]}
|
|
32
|
+
<Check size={14} className={cn("ms-auto", lang !== l && "hidden")} />
|
|
33
|
+
</DropdownMenuItem>
|
|
34
|
+
))}
|
|
35
|
+
</DropdownMenuContent>
|
|
36
|
+
</DropdownMenu>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Sidebar,
|
|
3
|
+
SidebarContent,
|
|
4
|
+
SidebarFooter,
|
|
5
|
+
SidebarHeader,
|
|
6
|
+
SidebarRail,
|
|
7
|
+
} from "@icomponents/react/components/ui/sidebar";
|
|
8
|
+
import { useLayout } from "@icomponents/react/context/layout-provider";
|
|
9
|
+
import { TESTID, testid } from "@icomponents/react/testids";
|
|
10
|
+
|
|
11
|
+
// import { AppTitle } from './app-title'
|
|
12
|
+
import { NavGroup } from "./nav-group";
|
|
13
|
+
import { NavUser, type NavUserItem } from "./nav-user";
|
|
14
|
+
import { TeamSwitcher } from "./team-switcher";
|
|
15
|
+
import { type NavGroup as NavGroupType } from "./types";
|
|
16
|
+
|
|
17
|
+
type Team = {
|
|
18
|
+
name: string;
|
|
19
|
+
logo: React.ElementType;
|
|
20
|
+
plan: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type AppSidebarProps = {
|
|
24
|
+
user: {
|
|
25
|
+
name: string;
|
|
26
|
+
email: string;
|
|
27
|
+
avatar?: string;
|
|
28
|
+
};
|
|
29
|
+
navGroups: NavGroupType[];
|
|
30
|
+
teams: Team[];
|
|
31
|
+
navUserItems: NavUserItem[];
|
|
32
|
+
onSignOut: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function AppSidebar({ user, navGroups, teams, navUserItems, onSignOut }: AppSidebarProps) {
|
|
36
|
+
const { collapsible, variant } = useLayout();
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Sidebar collapsible={collapsible} variant={variant}>
|
|
40
|
+
<SidebarHeader>
|
|
41
|
+
<TeamSwitcher teams={teams} />
|
|
42
|
+
|
|
43
|
+
{/* Replace <TeamSwitch /> with the following <AppTitle />
|
|
44
|
+
/* if you want to use the normal app title instead of TeamSwitch dropdown */}
|
|
45
|
+
{/* <AppTitle /> */}
|
|
46
|
+
</SidebarHeader>
|
|
47
|
+
{/* Пропала навигация — человек заперт на одном экране, и это не «косметика» (см. testids.ts). */}
|
|
48
|
+
<SidebarContent {...testid(TESTID.nav)}>
|
|
49
|
+
{navGroups.map((props) => (
|
|
50
|
+
<NavGroup key={props.title} {...props} />
|
|
51
|
+
))}
|
|
52
|
+
</SidebarContent>
|
|
53
|
+
<SidebarFooter>
|
|
54
|
+
<NavUser user={user} items={navUserItems} onSignOut={onSignOut} />
|
|
55
|
+
</SidebarFooter>
|
|
56
|
+
<SidebarRail />
|
|
57
|
+
</Sidebar>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SidebarMenu,
|
|
3
|
+
SidebarMenuButton,
|
|
4
|
+
SidebarMenuItem,
|
|
5
|
+
useSidebar,
|
|
6
|
+
} from "@icomponents/react/components/ui/sidebar";
|
|
7
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
8
|
+
import { Link } from "@tanstack/react-router";
|
|
9
|
+
import { Menu, X } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
import { Button } from "../ui/button";
|
|
12
|
+
|
|
13
|
+
// `appName` mirrors config.appName (default "Admin Console") — threaded as a prop since this is a
|
|
14
|
+
// client component with no server-config seam; callers that have the runtime config can pass it.
|
|
15
|
+
export function AppTitle({ appName = "Admin Console" }: { appName?: string } = {}) {
|
|
16
|
+
const { setOpenMobile } = useSidebar();
|
|
17
|
+
return (
|
|
18
|
+
<SidebarMenu>
|
|
19
|
+
<SidebarMenuItem>
|
|
20
|
+
<SidebarMenuButton
|
|
21
|
+
size="lg"
|
|
22
|
+
className="gap-0 py-0 hover:bg-transparent active:bg-transparent"
|
|
23
|
+
asChild
|
|
24
|
+
>
|
|
25
|
+
<div>
|
|
26
|
+
<Link
|
|
27
|
+
to="/"
|
|
28
|
+
onClick={() => setOpenMobile(false)}
|
|
29
|
+
className="grid flex-1 text-start text-sm leading-tight"
|
|
30
|
+
>
|
|
31
|
+
<span className="truncate font-bold">{appName}</span>
|
|
32
|
+
</Link>
|
|
33
|
+
<ToggleSidebar />
|
|
34
|
+
</div>
|
|
35
|
+
</SidebarMenuButton>
|
|
36
|
+
</SidebarMenuItem>
|
|
37
|
+
</SidebarMenu>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ToggleSidebar({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
|
42
|
+
const { toggleSidebar } = useSidebar();
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Button
|
|
46
|
+
data-sidebar="trigger"
|
|
47
|
+
data-slot="sidebar-trigger"
|
|
48
|
+
variant="ghost"
|
|
49
|
+
size="icon"
|
|
50
|
+
className={cn("aspect-square size-8 max-md:scale-125", className)}
|
|
51
|
+
onClick={(event) => {
|
|
52
|
+
onClick?.(event);
|
|
53
|
+
toggleSidebar();
|
|
54
|
+
}}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
<X className="md:hidden" />
|
|
58
|
+
<Menu className="max-md:hidden" />
|
|
59
|
+
<span className="sr-only">Toggle Sidebar</span>
|
|
60
|
+
</Button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Separator } from "@icomponents/react/components/ui/separator";
|
|
2
|
+
import { SidebarTrigger } from "@icomponents/react/components/ui/sidebar";
|
|
3
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
|
|
7
|
+
fixed?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Suppress the Header's own SidebarTrigger (+ its leading separator). The global
|
|
10
|
+
* SidebarRail already provides a "Toggle Sidebar" affordance on every authenticated
|
|
11
|
+
* page, so a page that would otherwise show two toggles (e.g. /company-audit) opts out
|
|
12
|
+
* here. Default false keeps every other Header page unchanged (AC-F4-5, narrow fix).
|
|
13
|
+
*/
|
|
14
|
+
hideSidebarTrigger?: boolean;
|
|
15
|
+
ref?: React.Ref<HTMLElement>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function Header({ className, fixed, hideSidebarTrigger, children, ...props }: HeaderProps) {
|
|
19
|
+
const [offset, setOffset] = useState(0);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const onScroll = () => {
|
|
23
|
+
setOffset(document.body.scrollTop || document.documentElement.scrollTop);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Add scroll listener to the body
|
|
27
|
+
document.addEventListener("scroll", onScroll, { passive: true });
|
|
28
|
+
|
|
29
|
+
// Clean up the event listener on unmount
|
|
30
|
+
return () => document.removeEventListener("scroll", onScroll);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<header
|
|
35
|
+
className={cn(
|
|
36
|
+
"z-50 h-16",
|
|
37
|
+
fixed && "header-fixed peer/header sticky top-0 w-[inherit]",
|
|
38
|
+
offset > 10 && fixed ? "shadow" : "shadow-none",
|
|
39
|
+
className
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
"relative flex h-full items-center gap-3 p-4 sm:gap-4",
|
|
46
|
+
offset > 10 &&
|
|
47
|
+
fixed &&
|
|
48
|
+
"after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg"
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{!hideSidebarTrigger && (
|
|
52
|
+
<>
|
|
53
|
+
<SidebarTrigger variant="outline" className="max-md:scale-125" />
|
|
54
|
+
<Separator orientation="vertical" className="h-6" />
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
</header>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
2
|
+
import { TESTID, testid } from "@icomponents/react/testids";
|
|
3
|
+
|
|
4
|
+
type MainProps = React.HTMLAttributes<HTMLElement> & {
|
|
5
|
+
fixed?: boolean;
|
|
6
|
+
fluid?: boolean;
|
|
7
|
+
ref?: React.Ref<HTMLElement>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Контейнер содержимого КАЖДОГО экрана. Несёт `data-testid="screen"` — это и есть признак «экран
|
|
12
|
+
* смонтировался», по которому e2e отличает живой экран от каркаса и от страницы ошибки. Вешаем здесь,
|
|
13
|
+
* а не в шапке страницы: шапку экран может и не использовать (дашборд не использует), а контейнер —
|
|
14
|
+
* использует всегда.
|
|
15
|
+
*/
|
|
16
|
+
export function Main({ fixed, className, fluid, ...props }: MainProps) {
|
|
17
|
+
return (
|
|
18
|
+
<main
|
|
19
|
+
{...testid(TESTID.screen)}
|
|
20
|
+
data-layout={fixed ? "fixed" : "auto"}
|
|
21
|
+
className={cn(
|
|
22
|
+
"px-4 py-6",
|
|
23
|
+
|
|
24
|
+
// If layout is fixed, make the main container flex and grow
|
|
25
|
+
fixed && "flex grow flex-col overflow-hidden",
|
|
26
|
+
|
|
27
|
+
// If layout is not fluid, set the max-width
|
|
28
|
+
!fluid && "@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|