@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,108 @@
|
|
|
1
|
+
import { getCookie, setCookie, removeCookie } from "@icomponents/react/lib/cookies";
|
|
2
|
+
import { createContext, useContext, useEffect, useState, useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
type Theme = "dark" | "light" | "system";
|
|
5
|
+
type ResolvedTheme = Exclude<Theme, "system">;
|
|
6
|
+
|
|
7
|
+
const DEFAULT_THEME = "system";
|
|
8
|
+
const THEME_COOKIE_NAME = "ui-theme";
|
|
9
|
+
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
10
|
+
|
|
11
|
+
type ThemeProviderProps = {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
defaultTheme?: Theme;
|
|
14
|
+
storageKey?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ThemeProviderState = {
|
|
18
|
+
defaultTheme: Theme;
|
|
19
|
+
resolvedTheme: ResolvedTheme;
|
|
20
|
+
theme: Theme;
|
|
21
|
+
setTheme: (theme: Theme) => void;
|
|
22
|
+
resetTheme: () => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const initialState: ThemeProviderState = {
|
|
26
|
+
defaultTheme: DEFAULT_THEME,
|
|
27
|
+
resolvedTheme: "light",
|
|
28
|
+
theme: DEFAULT_THEME,
|
|
29
|
+
setTheme: () => null,
|
|
30
|
+
resetTheme: () => null,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ThemeContext = createContext<ThemeProviderState>(initialState);
|
|
34
|
+
|
|
35
|
+
export function ThemeProvider({
|
|
36
|
+
children,
|
|
37
|
+
defaultTheme = DEFAULT_THEME,
|
|
38
|
+
storageKey = THEME_COOKIE_NAME,
|
|
39
|
+
...props
|
|
40
|
+
}: ThemeProviderProps) {
|
|
41
|
+
const [theme, _setTheme] = useState<Theme>(
|
|
42
|
+
() => (getCookie(storageKey) as Theme) || defaultTheme
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Optimized: Memoize the resolved theme calculation to prevent unnecessary re-computations
|
|
46
|
+
const resolvedTheme = useMemo((): ResolvedTheme => {
|
|
47
|
+
if (theme === "system") {
|
|
48
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
49
|
+
}
|
|
50
|
+
return theme as ResolvedTheme;
|
|
51
|
+
}, [theme]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const root = window.document.documentElement;
|
|
55
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
56
|
+
|
|
57
|
+
const applyTheme = (currentResolvedTheme: ResolvedTheme) => {
|
|
58
|
+
root.classList.remove("light", "dark"); // Remove existing theme classes
|
|
59
|
+
root.classList.add(currentResolvedTheme); // Add the new theme class
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleChange = () => {
|
|
63
|
+
if (theme === "system") {
|
|
64
|
+
const systemTheme = mediaQuery.matches ? "dark" : "light";
|
|
65
|
+
applyTheme(systemTheme);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
applyTheme(resolvedTheme);
|
|
70
|
+
|
|
71
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
72
|
+
|
|
73
|
+
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
74
|
+
}, [theme, resolvedTheme]);
|
|
75
|
+
|
|
76
|
+
const setTheme = (theme: Theme) => {
|
|
77
|
+
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE);
|
|
78
|
+
_setTheme(theme);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const resetTheme = () => {
|
|
82
|
+
removeCookie(storageKey);
|
|
83
|
+
_setTheme(DEFAULT_THEME);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const contextValue = {
|
|
87
|
+
defaultTheme,
|
|
88
|
+
resolvedTheme,
|
|
89
|
+
resetTheme,
|
|
90
|
+
theme,
|
|
91
|
+
setTheme,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<ThemeContext value={contextValue} {...props}>
|
|
96
|
+
{children}
|
|
97
|
+
</ThemeContext>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
102
|
+
export const useTheme = () => {
|
|
103
|
+
const context = useContext(ThemeContext);
|
|
104
|
+
|
|
105
|
+
if (!context) throw new Error("useTheme must be used within a ThemeProvider");
|
|
106
|
+
|
|
107
|
+
return context;
|
|
108
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { sortParamToState, sortStateToParam } from "./use-table-url-state";
|
|
4
|
+
|
|
5
|
+
test("sortParamToState: '-name' → desc, 'name' → asc, empty → []", () => {
|
|
6
|
+
expect(sortParamToState("-name")).toEqual([{ id: "name", desc: true }]);
|
|
7
|
+
expect(sortParamToState("name")).toEqual([{ id: "name", desc: false }]);
|
|
8
|
+
expect(sortParamToState("")).toEqual([]);
|
|
9
|
+
expect(sortParamToState(undefined)).toEqual([]);
|
|
10
|
+
expect(sortParamToState("-")).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
test("sortStateToParam: round-trips, [] → undefined", () => {
|
|
13
|
+
expect(sortStateToParam([{ id: "name", desc: true }])).toBe("-name");
|
|
14
|
+
expect(sortStateToParam([{ id: "name", desc: false }])).toBe("name");
|
|
15
|
+
expect(sortStateToParam([])).toBeUndefined();
|
|
16
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom hook for confirm dialog
|
|
5
|
+
* @param initialState string | null
|
|
6
|
+
* @returns A stateful value, and a function to update it.
|
|
7
|
+
* @example const [open, setOpen] = useDialogState<"approve" | "reject">()
|
|
8
|
+
*/
|
|
9
|
+
export default function useDialogState<T extends string | boolean>(initialState: T | null = null) {
|
|
10
|
+
const [open, _setOpen] = useState<T | null>(initialState);
|
|
11
|
+
|
|
12
|
+
const setOpen = (str: T | null) => _setOpen((prev) => (prev === str ? null : str));
|
|
13
|
+
|
|
14
|
+
return [open, setOpen] as const;
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768;
|
|
4
|
+
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
|
5
|
+
|
|
6
|
+
export function useIsMobile() {
|
|
7
|
+
return React.useSyncExternalStore(
|
|
8
|
+
(callback) => {
|
|
9
|
+
const mql = window.matchMedia(MOBILE_QUERY);
|
|
10
|
+
mql.addEventListener("change", callback);
|
|
11
|
+
return () => mql.removeEventListener("change", callback);
|
|
12
|
+
},
|
|
13
|
+
() => window.matchMedia(MOBILE_QUERY).matches,
|
|
14
|
+
() => false
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnFiltersState,
|
|
3
|
+
OnChangeFn,
|
|
4
|
+
PaginationState,
|
|
5
|
+
SortingState,
|
|
6
|
+
} from "@tanstack/react-table";
|
|
7
|
+
import { useMemo, useState } from "react";
|
|
8
|
+
|
|
9
|
+
type SearchRecord = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export type NavigateFn = (opts: {
|
|
12
|
+
search: true | SearchRecord | ((prev: SearchRecord) => Partial<SearchRecord> | SearchRecord);
|
|
13
|
+
replace?: boolean;
|
|
14
|
+
}) => void;
|
|
15
|
+
|
|
16
|
+
/** URL sort param → single-column SortingState. `"-field"` = desc, `"field"` = asc, else []. */
|
|
17
|
+
export function sortParamToState(raw: unknown): SortingState {
|
|
18
|
+
if (typeof raw !== "string" || raw.trim() === "") return [];
|
|
19
|
+
const isDesc = raw.startsWith("-");
|
|
20
|
+
const id = (isDesc ? raw.slice(1) : raw).trim();
|
|
21
|
+
if (!id) return [];
|
|
22
|
+
return [{ id, desc: isDesc }];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Single-column SortingState → URL sort param (`"-field"`/`"field"`), or undefined when unsorted. */
|
|
26
|
+
export function sortStateToParam(sorting: SortingState): string | undefined {
|
|
27
|
+
const first = sorting[0];
|
|
28
|
+
if (!first) return undefined;
|
|
29
|
+
return `${first.desc ? "-" : ""}${first.id}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type UseTableUrlStateParams = {
|
|
33
|
+
search: SearchRecord;
|
|
34
|
+
navigate: NavigateFn;
|
|
35
|
+
pagination?: {
|
|
36
|
+
pageKey?: string;
|
|
37
|
+
pageSizeKey?: string;
|
|
38
|
+
defaultPage?: number;
|
|
39
|
+
defaultPageSize?: number;
|
|
40
|
+
};
|
|
41
|
+
globalFilter?: {
|
|
42
|
+
enabled?: boolean;
|
|
43
|
+
key?: string;
|
|
44
|
+
trim?: boolean;
|
|
45
|
+
};
|
|
46
|
+
columnFilters?: Array<
|
|
47
|
+
| {
|
|
48
|
+
columnId: string;
|
|
49
|
+
searchKey: string;
|
|
50
|
+
type?: "string";
|
|
51
|
+
// Optional transformers for custom types
|
|
52
|
+
serialize?: (value: unknown) => unknown;
|
|
53
|
+
deserialize?: (value: unknown) => unknown;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
columnId: string;
|
|
57
|
+
searchKey: string;
|
|
58
|
+
type: "array";
|
|
59
|
+
serialize?: (value: unknown) => unknown;
|
|
60
|
+
deserialize?: (value: unknown) => unknown;
|
|
61
|
+
}
|
|
62
|
+
>;
|
|
63
|
+
sorting?: {
|
|
64
|
+
enabled?: boolean; // default false — client-mode tables leave it off (local sort), unchanged
|
|
65
|
+
key?: string; // URL param, default "sort"
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type UseTableUrlStateReturn = {
|
|
70
|
+
// Global filter
|
|
71
|
+
globalFilter?: string;
|
|
72
|
+
onGlobalFilterChange?: OnChangeFn<string>;
|
|
73
|
+
// Column filters
|
|
74
|
+
columnFilters: ColumnFiltersState;
|
|
75
|
+
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
|
|
76
|
+
// Pagination
|
|
77
|
+
pagination: PaginationState;
|
|
78
|
+
onPaginationChange: OnChangeFn<PaginationState>;
|
|
79
|
+
// Sorting (present only when enabled)
|
|
80
|
+
sorting?: SortingState;
|
|
81
|
+
onSortingChange?: OnChangeFn<SortingState>;
|
|
82
|
+
// Helpers
|
|
83
|
+
ensurePageInRange: (pageCount: number, opts?: { resetTo?: "first" | "last" }) => void;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function useTableUrlState(params: UseTableUrlStateParams): UseTableUrlStateReturn {
|
|
87
|
+
const {
|
|
88
|
+
search,
|
|
89
|
+
navigate,
|
|
90
|
+
pagination: paginationCfg,
|
|
91
|
+
globalFilter: globalFilterCfg,
|
|
92
|
+
columnFilters: columnFiltersCfg = [],
|
|
93
|
+
sorting: sortingCfg,
|
|
94
|
+
} = params;
|
|
95
|
+
|
|
96
|
+
const pageKey = paginationCfg?.pageKey ?? ("page" as string);
|
|
97
|
+
const pageSizeKey = paginationCfg?.pageSizeKey ?? ("pageSize" as string);
|
|
98
|
+
const defaultPage = paginationCfg?.defaultPage ?? 1;
|
|
99
|
+
const defaultPageSize = paginationCfg?.defaultPageSize ?? 10;
|
|
100
|
+
|
|
101
|
+
const globalFilterKey = globalFilterCfg?.key ?? ("filter" as string);
|
|
102
|
+
const globalFilterEnabled = globalFilterCfg?.enabled ?? true;
|
|
103
|
+
const trimGlobal = globalFilterCfg?.trim ?? true;
|
|
104
|
+
|
|
105
|
+
// Build initial column filters from the current search params
|
|
106
|
+
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
|
|
107
|
+
const collected: ColumnFiltersState = [];
|
|
108
|
+
for (const cfg of columnFiltersCfg) {
|
|
109
|
+
const raw = (search as SearchRecord)[cfg.searchKey];
|
|
110
|
+
const deserialize = cfg.deserialize ?? ((v: unknown) => v);
|
|
111
|
+
if (cfg.type === "string") {
|
|
112
|
+
const value = (deserialize(raw) as string) ?? "";
|
|
113
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
114
|
+
collected.push({ id: cfg.columnId, value });
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// default to array type
|
|
118
|
+
const value = (deserialize(raw) as unknown[]) ?? [];
|
|
119
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
120
|
+
collected.push({ id: cfg.columnId, value });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return collected;
|
|
125
|
+
}, [columnFiltersCfg, search]);
|
|
126
|
+
|
|
127
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(initialColumnFilters);
|
|
128
|
+
|
|
129
|
+
const pagination: PaginationState = useMemo(() => {
|
|
130
|
+
const rawPage = (search as SearchRecord)[pageKey];
|
|
131
|
+
const rawPageSize = (search as SearchRecord)[pageSizeKey];
|
|
132
|
+
const pageNum = typeof rawPage === "number" ? rawPage : defaultPage;
|
|
133
|
+
const pageSizeNum = typeof rawPageSize === "number" ? rawPageSize : defaultPageSize;
|
|
134
|
+
return { pageIndex: Math.max(0, pageNum - 1), pageSize: pageSizeNum };
|
|
135
|
+
}, [search, pageKey, pageSizeKey, defaultPage, defaultPageSize]);
|
|
136
|
+
|
|
137
|
+
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
138
|
+
const next = typeof updater === "function" ? updater(pagination) : updater;
|
|
139
|
+
const nextPage = next.pageIndex + 1;
|
|
140
|
+
const nextPageSize = next.pageSize;
|
|
141
|
+
navigate({
|
|
142
|
+
search: (prev) => ({
|
|
143
|
+
...(prev as SearchRecord),
|
|
144
|
+
[pageKey]: nextPage <= defaultPage ? undefined : nextPage,
|
|
145
|
+
[pageSizeKey]: nextPageSize === defaultPageSize ? undefined : nextPageSize,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const sortingEnabled = sortingCfg?.enabled ?? false;
|
|
151
|
+
const sortKey = sortingCfg?.key ?? "sort";
|
|
152
|
+
const sortingState: SortingState = useMemo(
|
|
153
|
+
() => (sortingEnabled ? sortParamToState((search as SearchRecord)[sortKey]) : []),
|
|
154
|
+
[sortingEnabled, search, sortKey]
|
|
155
|
+
);
|
|
156
|
+
const onSortingChange: OnChangeFn<SortingState> = (updater) => {
|
|
157
|
+
const next = typeof updater === "function" ? updater(sortingState) : updater;
|
|
158
|
+
navigate({
|
|
159
|
+
search: (prev) => ({
|
|
160
|
+
...(prev as SearchRecord),
|
|
161
|
+
[pageKey]: undefined, // a sort change resets to page 1 (mirrors filter/pagination changes)
|
|
162
|
+
[sortKey]: sortStateToParam(next),
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const [globalFilter, setGlobalFilter] = useState<string | undefined>(() => {
|
|
168
|
+
if (!globalFilterEnabled) return undefined;
|
|
169
|
+
const raw = (search as SearchRecord)[globalFilterKey];
|
|
170
|
+
return typeof raw === "string" ? raw : "";
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const onGlobalFilterChange: OnChangeFn<string> | undefined = globalFilterEnabled
|
|
174
|
+
? (updater) => {
|
|
175
|
+
const next = typeof updater === "function" ? updater(globalFilter ?? "") : updater;
|
|
176
|
+
const value = trimGlobal ? next.trim() : next;
|
|
177
|
+
setGlobalFilter(value);
|
|
178
|
+
navigate({
|
|
179
|
+
search: (prev) => ({
|
|
180
|
+
...(prev as SearchRecord),
|
|
181
|
+
[pageKey]: undefined,
|
|
182
|
+
[globalFilterKey]: value ? value : undefined,
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
: undefined;
|
|
187
|
+
|
|
188
|
+
const onColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
|
|
189
|
+
const next = typeof updater === "function" ? updater(columnFilters) : updater;
|
|
190
|
+
setColumnFilters(next);
|
|
191
|
+
|
|
192
|
+
const patch: Record<string, unknown> = {};
|
|
193
|
+
|
|
194
|
+
for (const cfg of columnFiltersCfg) {
|
|
195
|
+
const found = next.find((f) => f.id === cfg.columnId);
|
|
196
|
+
const serialize = cfg.serialize ?? ((v: unknown) => v);
|
|
197
|
+
if (cfg.type === "string") {
|
|
198
|
+
const value = typeof found?.value === "string" ? (found.value as string) : "";
|
|
199
|
+
patch[cfg.searchKey] = value.trim() !== "" ? serialize(value) : undefined;
|
|
200
|
+
} else {
|
|
201
|
+
const value = Array.isArray(found?.value) ? (found!.value as unknown[]) : [];
|
|
202
|
+
patch[cfg.searchKey] = value.length > 0 ? serialize(value) : undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
navigate({
|
|
207
|
+
search: (prev) => ({
|
|
208
|
+
...(prev as SearchRecord),
|
|
209
|
+
[pageKey]: undefined,
|
|
210
|
+
...patch,
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const ensurePageInRange = (
|
|
216
|
+
pageCount: number,
|
|
217
|
+
opts: { resetTo?: "first" | "last" } = { resetTo: "first" }
|
|
218
|
+
) => {
|
|
219
|
+
const currentPage = (search as SearchRecord)[pageKey];
|
|
220
|
+
const pageNum = typeof currentPage === "number" ? currentPage : defaultPage;
|
|
221
|
+
if (pageCount > 0 && pageNum > pageCount) {
|
|
222
|
+
navigate({
|
|
223
|
+
replace: true,
|
|
224
|
+
search: (prev) => ({
|
|
225
|
+
...(prev as SearchRecord),
|
|
226
|
+
[pageKey]: opts.resetTo === "last" ? pageCount : undefined,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
globalFilter: globalFilterEnabled ? (globalFilter ?? "") : undefined,
|
|
234
|
+
onGlobalFilterChange,
|
|
235
|
+
columnFilters,
|
|
236
|
+
onColumnFiltersChange,
|
|
237
|
+
pagination,
|
|
238
|
+
onPaginationChange,
|
|
239
|
+
sorting: sortingEnabled ? sortingState : undefined,
|
|
240
|
+
onSortingChange: sortingEnabled ? onSortingChange : undefined,
|
|
241
|
+
ensurePageInRange,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { NamespaceBundle } from "@icomponents/react/i18n/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Словарь ОБЩИХ shared-компонентов ядра — тех, что не принадлежат ни одному слайсу, а стоят в шапке
|
|
5
|
+
* и в таблицах на всех страницах: config-drawer, search, command-menu, theme-switch, faceted-filter.
|
|
6
|
+
* Неймспейс "common".
|
|
7
|
+
*
|
|
8
|
+
* Живёт в `packages/ui` рядом с самими компонентами (в отличие от per-slice бандлов, которые лежат у
|
|
9
|
+
* своего слайса): у этих компонентов нет слайса-владельца. Собирается тем же приёмом — продукт
|
|
10
|
+
* добавляет бандл в `appBundles` (см. `apps/web/src/client/i18n/bundles.ts`) и при желании
|
|
11
|
+
* переопределяет подписи через `createI18n({ overrides })`, не форкая ядро.
|
|
12
|
+
*/
|
|
13
|
+
export const commonBundle: NamespaceBundle = {
|
|
14
|
+
ns: "common",
|
|
15
|
+
messages: {
|
|
16
|
+
ru: {
|
|
17
|
+
// Общее — переиспользуется несколькими компонентами.
|
|
18
|
+
noResults: "Ничего не найдено.",
|
|
19
|
+
// Диалог подтверждения (ConfirmDialog) — подписи кнопок по умолчанию. Раньше были захардкожены
|
|
20
|
+
// по-английски, из-за чего КАЖДЫЙ диалог, не передавший свои подписи, говорил не на языке
|
|
21
|
+
// интерфейса — в том числе диалог выхода.
|
|
22
|
+
confirm: {
|
|
23
|
+
cancel: "Отмена",
|
|
24
|
+
continue: "Продолжить",
|
|
25
|
+
},
|
|
26
|
+
// Выход из консоли (SignOutDialog).
|
|
27
|
+
signOut: {
|
|
28
|
+
title: "Выйти?",
|
|
29
|
+
desc: "Вы уверены, что хотите выйти? Чтобы вернуться в консоль, потребуется войти заново.",
|
|
30
|
+
confirm: "Выйти",
|
|
31
|
+
},
|
|
32
|
+
theme: {
|
|
33
|
+
title: "Тема",
|
|
34
|
+
light: "Светлая",
|
|
35
|
+
dark: "Тёмная",
|
|
36
|
+
system: "Системная",
|
|
37
|
+
toggle: "Переключить тему",
|
|
38
|
+
},
|
|
39
|
+
search: {
|
|
40
|
+
button: "Поиск",
|
|
41
|
+
},
|
|
42
|
+
commandMenu: {
|
|
43
|
+
placeholder: "Введите команду или поисковый запрос…",
|
|
44
|
+
},
|
|
45
|
+
configDrawer: {
|
|
46
|
+
open: "Открыть настройки оформления",
|
|
47
|
+
title: "Настройки оформления",
|
|
48
|
+
description: "Настройте внешний вид и раскладку под свои предпочтения.",
|
|
49
|
+
reset: "Сбросить",
|
|
50
|
+
resetAllAria: "Сбросить все настройки к значениям по умолчанию",
|
|
51
|
+
selectOption: "Выбрать: {{label}}",
|
|
52
|
+
optionPreview: "Превью варианта «{{label}}»",
|
|
53
|
+
theme: {
|
|
54
|
+
selectAria: "Выберите тему оформления",
|
|
55
|
+
resetAria: "Сбросить тему оформления к значению по умолчанию",
|
|
56
|
+
hint: "Выберите системную, светлую или тёмную тему",
|
|
57
|
+
},
|
|
58
|
+
sidebar: {
|
|
59
|
+
title: "Боковая панель",
|
|
60
|
+
selectAria: "Выберите стиль боковой панели",
|
|
61
|
+
resetAria: "Сбросить стиль боковой панели к значению по умолчанию",
|
|
62
|
+
inset: "Встроенная",
|
|
63
|
+
floating: "Плавающая",
|
|
64
|
+
sidebar: "Обычная",
|
|
65
|
+
hint: "Выберите встроенную, плавающую или обычную боковую панель",
|
|
66
|
+
},
|
|
67
|
+
layout: {
|
|
68
|
+
title: "Раскладка",
|
|
69
|
+
selectAria: "Выберите стиль раскладки",
|
|
70
|
+
resetAria: "Сбросить параметры раскладки к значениям по умолчанию",
|
|
71
|
+
default: "Обычная",
|
|
72
|
+
compact: "Компактная",
|
|
73
|
+
full: "Во всю ширину",
|
|
74
|
+
hint: "Выберите обычную развёрнутую, компактную (только иконки) или полноширинную раскладку",
|
|
75
|
+
},
|
|
76
|
+
direction: {
|
|
77
|
+
title: "Направление",
|
|
78
|
+
selectAria: "Выберите направление письма",
|
|
79
|
+
resetAria: "Сбросить направление письма к значению по умолчанию",
|
|
80
|
+
ltr: "Слева направо",
|
|
81
|
+
rtl: "Справа налево",
|
|
82
|
+
hint: "Выберите направление письма — слева направо или справа налево",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
facetedFilter: {
|
|
86
|
+
selected: "Выбрано: {{n}}",
|
|
87
|
+
clear: "Сбросить фильтры",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
en: {
|
|
91
|
+
// Shared — reused by several components.
|
|
92
|
+
noResults: "No results found.",
|
|
93
|
+
// ConfirmDialog default button labels (previously hardcoded English literals, which made every
|
|
94
|
+
// dialog that relied on them speak a language the rest of the UI did not).
|
|
95
|
+
confirm: {
|
|
96
|
+
cancel: "Cancel",
|
|
97
|
+
continue: "Continue",
|
|
98
|
+
},
|
|
99
|
+
// Console sign-out (SignOutDialog).
|
|
100
|
+
signOut: {
|
|
101
|
+
title: "Sign out?",
|
|
102
|
+
desc: "Are you sure you want to sign out? You will need to sign in again to access the console.",
|
|
103
|
+
confirm: "Sign out",
|
|
104
|
+
},
|
|
105
|
+
theme: {
|
|
106
|
+
title: "Theme",
|
|
107
|
+
light: "Light",
|
|
108
|
+
dark: "Dark",
|
|
109
|
+
system: "System",
|
|
110
|
+
toggle: "Toggle theme",
|
|
111
|
+
},
|
|
112
|
+
search: {
|
|
113
|
+
button: "Search",
|
|
114
|
+
},
|
|
115
|
+
commandMenu: {
|
|
116
|
+
placeholder: "Type a command or search...",
|
|
117
|
+
},
|
|
118
|
+
configDrawer: {
|
|
119
|
+
open: "Open theme settings",
|
|
120
|
+
title: "Theme Settings",
|
|
121
|
+
description: "Adjust the appearance and layout to suit your preferences.",
|
|
122
|
+
reset: "Reset",
|
|
123
|
+
resetAllAria: "Reset all settings to default values",
|
|
124
|
+
selectOption: "Select {{label}}",
|
|
125
|
+
optionPreview: "{{label}} option preview",
|
|
126
|
+
theme: {
|
|
127
|
+
selectAria: "Select theme preference",
|
|
128
|
+
resetAria: "Reset theme preference to default",
|
|
129
|
+
hint: "Choose between system preference, light mode, or dark mode",
|
|
130
|
+
},
|
|
131
|
+
sidebar: {
|
|
132
|
+
title: "Sidebar",
|
|
133
|
+
selectAria: "Select sidebar style",
|
|
134
|
+
resetAria: "Reset sidebar style to default",
|
|
135
|
+
inset: "Inset",
|
|
136
|
+
floating: "Floating",
|
|
137
|
+
sidebar: "Sidebar",
|
|
138
|
+
hint: "Choose between inset, floating, or standard sidebar layout",
|
|
139
|
+
},
|
|
140
|
+
layout: {
|
|
141
|
+
title: "Layout",
|
|
142
|
+
selectAria: "Select layout style",
|
|
143
|
+
resetAria: "Reset layout options to default",
|
|
144
|
+
default: "Default",
|
|
145
|
+
compact: "Compact",
|
|
146
|
+
full: "Full layout",
|
|
147
|
+
hint: "Choose between default expanded, compact icon-only, or full layout mode",
|
|
148
|
+
},
|
|
149
|
+
direction: {
|
|
150
|
+
title: "Direction",
|
|
151
|
+
selectAria: "Select site direction",
|
|
152
|
+
resetAria: "Reset text direction to default",
|
|
153
|
+
ltr: "Left to Right",
|
|
154
|
+
rtl: "Right to Left",
|
|
155
|
+
hint: "Choose between left-to-right or right-to-left site direction",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
facetedFilter: {
|
|
159
|
+
selected: "{{n}} selected",
|
|
160
|
+
clear: "Clear filters",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createI18n } from "./create-i18n";
|
|
4
|
+
import type { NamespaceBundle } from "./types";
|
|
5
|
+
|
|
6
|
+
const companies: NamespaceBundle = {
|
|
7
|
+
ns: "companies",
|
|
8
|
+
messages: {
|
|
9
|
+
ru: { title: "Компании" },
|
|
10
|
+
en: { title: "Companies" },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const nav: NamespaceBundle = {
|
|
14
|
+
ns: "nav",
|
|
15
|
+
messages: { ru: { home: "Главная" }, en: { home: "Home" } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("createI18n — сборка per-feature бандлов", () => {
|
|
19
|
+
it("каждый неймспейс отдаёт свой перевод; язык переключается", async () => {
|
|
20
|
+
const i18n = createI18n({ bundles: [companies, nav], lng: "ru" });
|
|
21
|
+
expect(i18n.t("title", { ns: "companies" })).toBe("Компании");
|
|
22
|
+
expect(i18n.t("home", { ns: "nav" })).toBe("Главная");
|
|
23
|
+
await i18n.changeLanguage("en");
|
|
24
|
+
expect(i18n.t("title", { ns: "companies" })).toBe("Companies");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("непереведённый язык падает на английский (fallback), а не на пустой ключ", async () => {
|
|
28
|
+
const partial: NamespaceBundle = {
|
|
29
|
+
ns: "x",
|
|
30
|
+
messages: { ru: {}, en: { only: "English only" } },
|
|
31
|
+
};
|
|
32
|
+
const i18n = createI18n({ bundles: [partial], lng: "ru" });
|
|
33
|
+
expect(i18n.t("only", { ns: "x" })).toBe("English only");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("product-override меняет подпись ядра, НЕ мутируя исходный бандл (шов ядро/продукт)", () => {
|
|
37
|
+
const i18n = createI18n({
|
|
38
|
+
bundles: [companies],
|
|
39
|
+
lng: "ru",
|
|
40
|
+
overrides: { ru: { companies: { title: "Партнёры" } } },
|
|
41
|
+
});
|
|
42
|
+
// UI продукта видит своё слово…
|
|
43
|
+
expect(i18n.t("title", { ns: "companies" })).toBe("Партнёры");
|
|
44
|
+
// …а канонический бандл ядра не тронут (общий на все инстансы).
|
|
45
|
+
expect(companies.messages.ru.title).toBe("Компании");
|
|
46
|
+
// Другой инстанс без override видит канон.
|
|
47
|
+
const plain = createI18n({ bundles: [companies], lng: "ru" });
|
|
48
|
+
expect(plain.t("title", { ns: "companies" })).toBe("Компании");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import i18next, { type i18n as I18n, type Resource } from "i18next";
|
|
2
|
+
import { initReactI18next } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
import { LOCALES, type Locale, type Messages, type NamespaceBundle } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Продуктовые переопределения подписей. КЛЮЧЕВОЙ шов «ядро/продукт»: внутреннее представление
|
|
8
|
+
* остаётся в терминах шаблона (`organizations`/`companies`), а продукт МЕНЯЕТ ПОДПИСЬ в UI, не форкая
|
|
9
|
+
* бандл ядра. Например, продукт, где та же сущность зовётся иначе, передаёт
|
|
10
|
+
* `{ ru: { nav: { companies: "<своё слово>" }, companies: { title: "<своё слово>" } } }` — код
|
|
11
|
+
* по-прежнему знает про companies, пользователь видит термин продукта.
|
|
12
|
+
*/
|
|
13
|
+
export type Overrides = Partial<Record<Locale, Record<string, Messages>>>;
|
|
14
|
+
|
|
15
|
+
function deepMerge(base: Record<string, unknown>, over: Record<string, unknown>): void {
|
|
16
|
+
for (const [k, v] of Object.entries(over)) {
|
|
17
|
+
if (v && typeof v === "object" && !Array.isArray(v) && typeof base[k] === "object") {
|
|
18
|
+
deepMerge(base[k] as Record<string, unknown>, v as Record<string, unknown>);
|
|
19
|
+
} else {
|
|
20
|
+
base[k] = v;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Собирает per-feature бандлы в один инстанс i18next.
|
|
27
|
+
*
|
|
28
|
+
* Ключевое: НЕТ центрального словаря. `bundles` приходят из composition root, где каждый слайс
|
|
29
|
+
* зарегистрировал свой `NamespaceBundle`. Здесь они лишь раскладываются в форму i18next
|
|
30
|
+
* (`resources[lng][ns] = messages`) — монолит не появляется даже на этом шаге, каждый неймспейс
|
|
31
|
+
* остаётся отдельным.
|
|
32
|
+
*
|
|
33
|
+
* Свой инстанс (`createInstance`), а не глобальный синглтон i18next: приложение может держать
|
|
34
|
+
* несколько (тесты, SSR), и глобальное состояние не течёт между ними.
|
|
35
|
+
*/
|
|
36
|
+
export function createI18n(opts: {
|
|
37
|
+
bundles: NamespaceBundle[];
|
|
38
|
+
lng: Locale;
|
|
39
|
+
/** Продуктовые переопределения подписей (см. Overrides) — накладываются ПОВЕРХ бандлов ядра. */
|
|
40
|
+
overrides?: Overrides;
|
|
41
|
+
}): I18n {
|
|
42
|
+
const resources: Record<string, Record<string, unknown>> = {};
|
|
43
|
+
for (const lng of LOCALES) resources[lng] = {};
|
|
44
|
+
for (const b of opts.bundles) {
|
|
45
|
+
for (const lng of LOCALES) {
|
|
46
|
+
// Копия, чтобы override не мутировал исходный бандл фичи (он общий на все инстансы).
|
|
47
|
+
resources[lng]![b.ns] = structuredClone(b.messages[lng]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Продуктовые переопределения — точечный deep-merge на канонические бандлы (ядро не форкаем).
|
|
51
|
+
for (const lng of LOCALES) {
|
|
52
|
+
const over = opts.overrides?.[lng];
|
|
53
|
+
if (over) deepMerge(resources[lng]!, over as Record<string, unknown>);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const instance = i18next.createInstance();
|
|
57
|
+
instance.use(initReactI18next).init({
|
|
58
|
+
lng: opts.lng,
|
|
59
|
+
fallbackLng: "en", // язык не выбран/строка не переведена → английский, а не пустой ключ
|
|
60
|
+
resources: resources as Resource,
|
|
61
|
+
ns: opts.bundles.map((b) => b.ns),
|
|
62
|
+
defaultNS: false, // неймспейс указывается явно: useTranslation("companies") — не даём монолитному default прорасти
|
|
63
|
+
interpolation: { escapeValue: false }, // React сам экранирует
|
|
64
|
+
returnNull: false,
|
|
65
|
+
});
|
|
66
|
+
return instance;
|
|
67
|
+
}
|