@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,401 @@
|
|
|
1
|
+
import { Radio as RadioItemPrim } from "@base-ui/react/radio";
|
|
2
|
+
import { RadioGroup as Radio } from "@base-ui/react/radio-group";
|
|
3
|
+
import { IconDir } from "@icomponents/react/assets/custom/icon-dir";
|
|
4
|
+
import { IconLayoutCompact } from "@icomponents/react/assets/custom/icon-layout-compact";
|
|
5
|
+
import { IconLayoutDefault } from "@icomponents/react/assets/custom/icon-layout-default";
|
|
6
|
+
import { IconLayoutFull } from "@icomponents/react/assets/custom/icon-layout-full";
|
|
7
|
+
import { IconSidebarFloating } from "@icomponents/react/assets/custom/icon-sidebar-floating";
|
|
8
|
+
import { IconSidebarInset } from "@icomponents/react/assets/custom/icon-sidebar-inset";
|
|
9
|
+
import { IconSidebarSidebar } from "@icomponents/react/assets/custom/icon-sidebar-sidebar";
|
|
10
|
+
import { IconThemeDark } from "@icomponents/react/assets/custom/icon-theme-dark";
|
|
11
|
+
import { IconThemeLight } from "@icomponents/react/assets/custom/icon-theme-light";
|
|
12
|
+
import { IconThemeSystem } from "@icomponents/react/assets/custom/icon-theme-system";
|
|
13
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
14
|
+
import {
|
|
15
|
+
Sheet,
|
|
16
|
+
SheetContent,
|
|
17
|
+
SheetDescription,
|
|
18
|
+
SheetFooter,
|
|
19
|
+
SheetHeader,
|
|
20
|
+
SheetTitle,
|
|
21
|
+
SheetTrigger,
|
|
22
|
+
} from "@icomponents/react/components/ui/sheet";
|
|
23
|
+
import { useDirection } from "@icomponents/react/context/direction-provider";
|
|
24
|
+
import { useOptionalLanguage } from "@icomponents/react/context/language-provider";
|
|
25
|
+
import { type Collapsible, useLayout } from "@icomponents/react/context/layout-provider";
|
|
26
|
+
import { useTheme } from "@icomponents/react/context/theme-provider";
|
|
27
|
+
import type { Locale } from "@icomponents/react/i18n/types";
|
|
28
|
+
import { useTranslation } from "@icomponents/react/i18n/use-translation";
|
|
29
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
30
|
+
import { CircleCheck, RotateCcw, Settings } from "lucide-react";
|
|
31
|
+
import { type SVGProps } from "react";
|
|
32
|
+
|
|
33
|
+
import { useSidebar } from "./ui/sidebar";
|
|
34
|
+
|
|
35
|
+
export function ConfigDrawer() {
|
|
36
|
+
const { setOpen } = useSidebar();
|
|
37
|
+
const { resetDir } = useDirection();
|
|
38
|
+
const { resetTheme } = useTheme();
|
|
39
|
+
const { resetLayout } = useLayout();
|
|
40
|
+
const language = useOptionalLanguage();
|
|
41
|
+
const { t } = useTranslation("common");
|
|
42
|
+
|
|
43
|
+
const handleReset = () => {
|
|
44
|
+
setOpen(true);
|
|
45
|
+
resetDir();
|
|
46
|
+
resetTheme();
|
|
47
|
+
resetLayout();
|
|
48
|
+
language?.resetLang();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Sheet>
|
|
53
|
+
<SheetTrigger
|
|
54
|
+
render={
|
|
55
|
+
<Button
|
|
56
|
+
size="icon"
|
|
57
|
+
variant="ghost"
|
|
58
|
+
aria-label={t("configDrawer.open")}
|
|
59
|
+
className="rounded-full"
|
|
60
|
+
>
|
|
61
|
+
<Settings aria-hidden="true" />
|
|
62
|
+
</Button>
|
|
63
|
+
}
|
|
64
|
+
/>
|
|
65
|
+
<SheetContent className="flex flex-col">
|
|
66
|
+
<SheetHeader className="pb-0 text-start">
|
|
67
|
+
<SheetTitle>{t("configDrawer.title")}</SheetTitle>
|
|
68
|
+
<SheetDescription>{t("configDrawer.description")}</SheetDescription>
|
|
69
|
+
</SheetHeader>
|
|
70
|
+
<div className="space-y-6 overflow-y-auto px-4">
|
|
71
|
+
<LanguageConfig />
|
|
72
|
+
<ThemeConfig />
|
|
73
|
+
<SidebarConfig />
|
|
74
|
+
<LayoutConfig />
|
|
75
|
+
<DirConfig />
|
|
76
|
+
</div>
|
|
77
|
+
<SheetFooter className="gap-2">
|
|
78
|
+
<Button
|
|
79
|
+
variant="destructive"
|
|
80
|
+
onClick={handleReset}
|
|
81
|
+
aria-label={t("configDrawer.resetAllAria")}
|
|
82
|
+
>
|
|
83
|
+
{t("configDrawer.reset")}
|
|
84
|
+
</Button>
|
|
85
|
+
</SheetFooter>
|
|
86
|
+
</SheetContent>
|
|
87
|
+
</Sheet>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function SectionTitle({
|
|
92
|
+
title,
|
|
93
|
+
showReset = false,
|
|
94
|
+
onReset,
|
|
95
|
+
resetAriaLabel,
|
|
96
|
+
className,
|
|
97
|
+
}: {
|
|
98
|
+
title: string;
|
|
99
|
+
showReset?: boolean;
|
|
100
|
+
onReset?: () => void;
|
|
101
|
+
/** Shown on the small per-section reset (RotateCcw) for accessibility and tests. */
|
|
102
|
+
resetAriaLabel?: string;
|
|
103
|
+
className?: string;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
className={cn(
|
|
108
|
+
"mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground",
|
|
109
|
+
className
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{title}
|
|
113
|
+
{showReset && onReset && (
|
|
114
|
+
<Button
|
|
115
|
+
type="button"
|
|
116
|
+
size="icon"
|
|
117
|
+
variant="secondary"
|
|
118
|
+
className="size-4 rounded-full"
|
|
119
|
+
onClick={onReset}
|
|
120
|
+
aria-label={resetAriaLabel}
|
|
121
|
+
>
|
|
122
|
+
<RotateCcw className="size-3" />
|
|
123
|
+
</Button>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function RadioGroupItem({
|
|
130
|
+
item,
|
|
131
|
+
isTheme = false,
|
|
132
|
+
}: {
|
|
133
|
+
item: {
|
|
134
|
+
value: string;
|
|
135
|
+
label: string;
|
|
136
|
+
icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement;
|
|
137
|
+
};
|
|
138
|
+
isTheme?: boolean;
|
|
139
|
+
}) {
|
|
140
|
+
const { t } = useTranslation("common");
|
|
141
|
+
return (
|
|
142
|
+
<RadioItemPrim.Root
|
|
143
|
+
value={item.value}
|
|
144
|
+
className={cn("group outline-none", "transition duration-200 ease-in")}
|
|
145
|
+
aria-label={t("configDrawer.selectOption", { label: item.label.toLowerCase() })}
|
|
146
|
+
aria-describedby={`${item.value}-description`}
|
|
147
|
+
>
|
|
148
|
+
<div
|
|
149
|
+
className={cn(
|
|
150
|
+
"relative rounded-[6px] ring-[1px] ring-border",
|
|
151
|
+
"group-data-checked:shadow-2xl group-data-checked:ring-primary",
|
|
152
|
+
"group-focus-visible:ring-2"
|
|
153
|
+
)}
|
|
154
|
+
role="img"
|
|
155
|
+
aria-hidden="false"
|
|
156
|
+
aria-label={t("configDrawer.optionPreview", { label: item.label })}
|
|
157
|
+
>
|
|
158
|
+
<CircleCheck
|
|
159
|
+
className={cn(
|
|
160
|
+
"size-6 fill-primary stroke-white",
|
|
161
|
+
"group-data-unchecked:hidden",
|
|
162
|
+
"absolute top-0 right-0 translate-x-1/2 -translate-y-1/2"
|
|
163
|
+
)}
|
|
164
|
+
aria-hidden="true"
|
|
165
|
+
/>
|
|
166
|
+
<item.icon
|
|
167
|
+
className={cn(
|
|
168
|
+
!isTheme &&
|
|
169
|
+
"fill-primary stroke-primary group-data-unchecked:fill-muted-foreground group-data-unchecked:stroke-muted-foreground"
|
|
170
|
+
)}
|
|
171
|
+
aria-hidden="true"
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="mt-1 text-xs" id={`${item.value}-description`} aria-live="polite">
|
|
175
|
+
{item.label}
|
|
176
|
+
</div>
|
|
177
|
+
</RadioItemPrim.Root>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const LANG_LABEL: Record<Locale, string> = { ru: "Русский", en: "English" };
|
|
182
|
+
|
|
183
|
+
/** Секция языка. Прячется, если приложение не обёрнуто в LanguageProvider (продукт ещё не включил i18n). */
|
|
184
|
+
function LanguageConfig() {
|
|
185
|
+
const language = useOptionalLanguage();
|
|
186
|
+
if (!language) return null;
|
|
187
|
+
const { defaultLang, lang, langs, setLang } = language;
|
|
188
|
+
return (
|
|
189
|
+
<div>
|
|
190
|
+
<SectionTitle
|
|
191
|
+
title="Язык · Language"
|
|
192
|
+
showReset={lang !== defaultLang}
|
|
193
|
+
onReset={() => setLang(defaultLang)}
|
|
194
|
+
resetAriaLabel="Reset language to default"
|
|
195
|
+
/>
|
|
196
|
+
<div className="flex gap-2">
|
|
197
|
+
{langs.map((l) => (
|
|
198
|
+
<Button
|
|
199
|
+
key={l}
|
|
200
|
+
type="button"
|
|
201
|
+
size="sm"
|
|
202
|
+
variant={l === lang ? "default" : "secondary"}
|
|
203
|
+
onClick={() => setLang(l)}
|
|
204
|
+
aria-pressed={l === lang}
|
|
205
|
+
>
|
|
206
|
+
{LANG_LABEL[l]}
|
|
207
|
+
</Button>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ThemeConfig() {
|
|
215
|
+
const { defaultTheme, theme, setTheme } = useTheme();
|
|
216
|
+
const { t } = useTranslation("common");
|
|
217
|
+
return (
|
|
218
|
+
<div>
|
|
219
|
+
<SectionTitle
|
|
220
|
+
title={t("theme.title")}
|
|
221
|
+
showReset={theme !== defaultTheme}
|
|
222
|
+
onReset={() => setTheme(defaultTheme)}
|
|
223
|
+
resetAriaLabel={t("configDrawer.theme.resetAria")}
|
|
224
|
+
/>
|
|
225
|
+
<Radio
|
|
226
|
+
value={theme}
|
|
227
|
+
onValueChange={(v) => setTheme(v as typeof theme)}
|
|
228
|
+
className="grid w-full max-w-md grid-cols-3 gap-4"
|
|
229
|
+
aria-label={t("configDrawer.theme.selectAria")}
|
|
230
|
+
aria-describedby="theme-description"
|
|
231
|
+
>
|
|
232
|
+
{[
|
|
233
|
+
{
|
|
234
|
+
value: "system",
|
|
235
|
+
label: t("theme.system"),
|
|
236
|
+
icon: IconThemeSystem,
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
value: "light",
|
|
240
|
+
label: t("theme.light"),
|
|
241
|
+
icon: IconThemeLight,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
value: "dark",
|
|
245
|
+
label: t("theme.dark"),
|
|
246
|
+
icon: IconThemeDark,
|
|
247
|
+
},
|
|
248
|
+
].map((item) => (
|
|
249
|
+
<RadioGroupItem key={item.value} item={item} isTheme />
|
|
250
|
+
))}
|
|
251
|
+
</Radio>
|
|
252
|
+
<div id="theme-description" className="sr-only">
|
|
253
|
+
{t("configDrawer.theme.hint")}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function SidebarConfig() {
|
|
260
|
+
const { defaultVariant, variant, setVariant } = useLayout();
|
|
261
|
+
const { t } = useTranslation("common");
|
|
262
|
+
return (
|
|
263
|
+
<div className="max-md:hidden">
|
|
264
|
+
<SectionTitle
|
|
265
|
+
title={t("configDrawer.sidebar.title")}
|
|
266
|
+
showReset={defaultVariant !== variant}
|
|
267
|
+
onReset={() => setVariant(defaultVariant)}
|
|
268
|
+
resetAriaLabel={t("configDrawer.sidebar.resetAria")}
|
|
269
|
+
/>
|
|
270
|
+
<Radio
|
|
271
|
+
value={variant}
|
|
272
|
+
onValueChange={(v) => setVariant(v as typeof variant)}
|
|
273
|
+
className="grid w-full max-w-md grid-cols-3 gap-4"
|
|
274
|
+
aria-label={t("configDrawer.sidebar.selectAria")}
|
|
275
|
+
aria-describedby="sidebar-description"
|
|
276
|
+
>
|
|
277
|
+
{[
|
|
278
|
+
{
|
|
279
|
+
value: "inset",
|
|
280
|
+
label: t("configDrawer.sidebar.inset"),
|
|
281
|
+
icon: IconSidebarInset,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
value: "floating",
|
|
285
|
+
label: t("configDrawer.sidebar.floating"),
|
|
286
|
+
icon: IconSidebarFloating,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
value: "sidebar",
|
|
290
|
+
label: t("configDrawer.sidebar.sidebar"),
|
|
291
|
+
icon: IconSidebarSidebar,
|
|
292
|
+
},
|
|
293
|
+
].map((item) => (
|
|
294
|
+
<RadioGroupItem key={item.value} item={item} />
|
|
295
|
+
))}
|
|
296
|
+
</Radio>
|
|
297
|
+
<div id="sidebar-description" className="sr-only">
|
|
298
|
+
{t("configDrawer.sidebar.hint")}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function LayoutConfig() {
|
|
305
|
+
const { open, setOpen } = useSidebar();
|
|
306
|
+
const { defaultCollapsible, collapsible, setCollapsible } = useLayout();
|
|
307
|
+
const { t } = useTranslation("common");
|
|
308
|
+
|
|
309
|
+
const radioState = open ? "default" : collapsible;
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div className="max-md:hidden">
|
|
313
|
+
<SectionTitle
|
|
314
|
+
title={t("configDrawer.layout.title")}
|
|
315
|
+
showReset={radioState !== "default"}
|
|
316
|
+
onReset={() => {
|
|
317
|
+
setOpen(true);
|
|
318
|
+
setCollapsible(defaultCollapsible);
|
|
319
|
+
}}
|
|
320
|
+
resetAriaLabel={t("configDrawer.layout.resetAria")}
|
|
321
|
+
/>
|
|
322
|
+
<Radio
|
|
323
|
+
value={radioState}
|
|
324
|
+
onValueChange={(v) => {
|
|
325
|
+
if (v === "default") {
|
|
326
|
+
setOpen(true);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
setOpen(false);
|
|
330
|
+
setCollapsible(v as Collapsible);
|
|
331
|
+
}}
|
|
332
|
+
className="grid w-full max-w-md grid-cols-3 gap-4"
|
|
333
|
+
aria-label={t("configDrawer.layout.selectAria")}
|
|
334
|
+
aria-describedby="layout-description"
|
|
335
|
+
>
|
|
336
|
+
{[
|
|
337
|
+
{
|
|
338
|
+
value: "default",
|
|
339
|
+
label: t("configDrawer.layout.default"),
|
|
340
|
+
icon: IconLayoutDefault,
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
value: "icon",
|
|
344
|
+
label: t("configDrawer.layout.compact"),
|
|
345
|
+
icon: IconLayoutCompact,
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
value: "offcanvas",
|
|
349
|
+
label: t("configDrawer.layout.full"),
|
|
350
|
+
icon: IconLayoutFull,
|
|
351
|
+
},
|
|
352
|
+
].map((item) => (
|
|
353
|
+
<RadioGroupItem key={item.value} item={item} />
|
|
354
|
+
))}
|
|
355
|
+
</Radio>
|
|
356
|
+
<div id="layout-description" className="sr-only">
|
|
357
|
+
{t("configDrawer.layout.hint")}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function DirConfig() {
|
|
364
|
+
const { defaultDir, dir, setDir } = useDirection();
|
|
365
|
+
const { t } = useTranslation("common");
|
|
366
|
+
return (
|
|
367
|
+
<div>
|
|
368
|
+
<SectionTitle
|
|
369
|
+
title={t("configDrawer.direction.title")}
|
|
370
|
+
showReset={defaultDir !== dir}
|
|
371
|
+
onReset={() => setDir(defaultDir)}
|
|
372
|
+
resetAriaLabel={t("configDrawer.direction.resetAria")}
|
|
373
|
+
/>
|
|
374
|
+
<Radio
|
|
375
|
+
value={dir}
|
|
376
|
+
onValueChange={(v) => setDir(v as typeof dir)}
|
|
377
|
+
className="grid w-full max-w-md grid-cols-3 gap-4"
|
|
378
|
+
aria-label={t("configDrawer.direction.selectAria")}
|
|
379
|
+
aria-describedby="direction-description"
|
|
380
|
+
>
|
|
381
|
+
{[
|
|
382
|
+
{
|
|
383
|
+
value: "ltr",
|
|
384
|
+
label: t("configDrawer.direction.ltr"),
|
|
385
|
+
icon: (props: SVGProps<SVGSVGElement>) => <IconDir dir="ltr" {...props} />,
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
value: "rtl",
|
|
389
|
+
label: t("configDrawer.direction.rtl"),
|
|
390
|
+
icon: (props: SVGProps<SVGSVGElement>) => <IconDir dir="rtl" {...props} />,
|
|
391
|
+
},
|
|
392
|
+
].map((item) => (
|
|
393
|
+
<RadioGroupItem key={item.value} item={item} />
|
|
394
|
+
))}
|
|
395
|
+
</Radio>
|
|
396
|
+
<div id="direction-description" className="sr-only">
|
|
397
|
+
{t("configDrawer.direction.hint")}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { TESTID } from "@icomponents/react/testids";
|
|
4
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
5
|
+
import { userEvent } from "@testing-library/user-event";
|
|
6
|
+
|
|
7
|
+
import { ConfirmDialog } from "./confirm-dialog";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DOM-тесты пишутся ПЕРЕД перекраской и специально так, чтобы её пережить.
|
|
11
|
+
*
|
|
12
|
+
* Правило одно: цепляемся за РОЛЬ и ТЕКСТ, а не за классы и разметку. Тема меняет классы, отступы и
|
|
13
|
+
* оттенки — и тест, написанный по `.btn-destructive`, покраснеет весь разом, ничего при этом не
|
|
14
|
+
* доказав. Тест, написанный по «кнопке с именем „Удалить“», покраснеет ровно тогда, когда кнопка
|
|
15
|
+
* действительно исчезла или перестала работать. Именно это и надо ловить после смены дизайна.
|
|
16
|
+
*
|
|
17
|
+
* Диалог подтверждения — самое дорогое место для регрессии: он стоит перед разрушительными
|
|
18
|
+
* действиями (suspend, terminate, удаление). Если после перекраски он перестанет подтверждать —
|
|
19
|
+
* пользователь удалит компанию одним кликом и узнает об этом последним.
|
|
20
|
+
*/
|
|
21
|
+
afterEach(cleanup);
|
|
22
|
+
|
|
23
|
+
const base = {
|
|
24
|
+
open: true,
|
|
25
|
+
onOpenChange: () => {},
|
|
26
|
+
title: "Удалить компанию?",
|
|
27
|
+
desc: "Действие необратимо.",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("ConfirmDialog — подтверждение разрушительного действия", () => {
|
|
31
|
+
it("диалог смонтирован и несёт заголовок с текстом (по контракту testid, а не по подписи)", () => {
|
|
32
|
+
render(<ConfirmDialog {...base} handleConfirm={() => {}} />);
|
|
33
|
+
expect(screen.getByTestId(TESTID.confirmDialog)).toBeDefined();
|
|
34
|
+
expect(screen.getByText("Удалить компанию?")).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("подтверждение вызывает действие ровно один раз", async () => {
|
|
38
|
+
const confirm = mock(() => {});
|
|
39
|
+
render(<ConfirmDialog {...base} confirmText="Удалить" handleConfirm={confirm} />);
|
|
40
|
+
|
|
41
|
+
await userEvent.click(screen.getByTestId(TESTID.confirmAccept));
|
|
42
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("отмена НЕ вызывает действие (иначе диалог — украшение, а не защита)", async () => {
|
|
46
|
+
const confirm = mock(() => {});
|
|
47
|
+
render(<ConfirmDialog {...base} confirmText="Удалить" handleConfirm={confirm} />);
|
|
48
|
+
|
|
49
|
+
await userEvent.click(screen.getByTestId(TESTID.confirmCancel));
|
|
50
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("disabled — кнопка подтверждения не срабатывает", async () => {
|
|
54
|
+
const confirm = mock(() => {});
|
|
55
|
+
render(<ConfirmDialog {...base} confirmText="Удалить" disabled handleConfirm={confirm} />);
|
|
56
|
+
|
|
57
|
+
await userEvent.click(screen.getByTestId(TESTID.confirmAccept));
|
|
58
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlertDialog,
|
|
3
|
+
AlertDialogCancel,
|
|
4
|
+
AlertDialogContent,
|
|
5
|
+
AlertDialogDescription,
|
|
6
|
+
AlertDialogFooter,
|
|
7
|
+
AlertDialogHeader,
|
|
8
|
+
AlertDialogTitle,
|
|
9
|
+
} from "@icomponents/react/components/ui/alert-dialog";
|
|
10
|
+
import { Button } from "@icomponents/react/components/ui/button";
|
|
11
|
+
import { useTranslation } from "@icomponents/react/i18n/use-translation";
|
|
12
|
+
import { cn } from "@icomponents/react/lib/utils";
|
|
13
|
+
import { TESTID, testid } from "@icomponents/react/testids";
|
|
14
|
+
|
|
15
|
+
type ConfirmDialogProps = {
|
|
16
|
+
open: boolean;
|
|
17
|
+
onOpenChange: (open: boolean) => void;
|
|
18
|
+
title: React.ReactNode;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
desc: React.JSX.Element | string;
|
|
21
|
+
cancelBtnText?: string;
|
|
22
|
+
confirmText?: React.ReactNode;
|
|
23
|
+
destructive?: boolean;
|
|
24
|
+
isLoading?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
} & ({ form: string; handleConfirm?: undefined } | { form?: undefined; handleConfirm: () => void });
|
|
28
|
+
|
|
29
|
+
export function ConfirmDialog(props: ConfirmDialogProps) {
|
|
30
|
+
// Подписи кнопок по умолчанию — из неймспейса "common", а не литералами: диалог подтверждения
|
|
31
|
+
// стоит перед КАЖДЫМ разрушительным действием, и вызывающие его экраны сплошь и рядом полагаются
|
|
32
|
+
// на дефолт. Литерал здесь означал бы английские «Cancel/Continue» посреди русского интерфейса
|
|
33
|
+
// ровно там, где человек решает, рушить или нет.
|
|
34
|
+
const { t } = useTranslation("common");
|
|
35
|
+
const {
|
|
36
|
+
title,
|
|
37
|
+
desc,
|
|
38
|
+
children,
|
|
39
|
+
className,
|
|
40
|
+
confirmText,
|
|
41
|
+
cancelBtnText,
|
|
42
|
+
destructive,
|
|
43
|
+
isLoading,
|
|
44
|
+
disabled = false,
|
|
45
|
+
form,
|
|
46
|
+
handleConfirm,
|
|
47
|
+
...actions
|
|
48
|
+
} = props;
|
|
49
|
+
return (
|
|
50
|
+
<AlertDialog {...actions}>
|
|
51
|
+
<AlertDialogContent {...testid(TESTID.confirmDialog)} className={cn(className && className)}>
|
|
52
|
+
<AlertDialogHeader className="text-start">
|
|
53
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
54
|
+
<AlertDialogDescription render={<div>{desc}</div>} />
|
|
55
|
+
</AlertDialogHeader>
|
|
56
|
+
{children}
|
|
57
|
+
<AlertDialogFooter>
|
|
58
|
+
<AlertDialogCancel {...testid(TESTID.confirmCancel)} disabled={isLoading}>
|
|
59
|
+
{cancelBtnText ?? t("confirm.cancel")}
|
|
60
|
+
</AlertDialogCancel>
|
|
61
|
+
<Button
|
|
62
|
+
{...testid(TESTID.confirmAccept)}
|
|
63
|
+
type={form ? "submit" : "button"}
|
|
64
|
+
form={form}
|
|
65
|
+
onClick={handleConfirm}
|
|
66
|
+
variant={destructive ? "destructive" : "default"}
|
|
67
|
+
disabled={disabled || isLoading}
|
|
68
|
+
>
|
|
69
|
+
{confirmText ?? t("confirm.continue")}
|
|
70
|
+
</Button>
|
|
71
|
+
</AlertDialogFooter>
|
|
72
|
+
</AlertDialogContent>
|
|
73
|
+
</AlertDialog>
|
|
74
|
+
);
|
|
75
|
+
}
|