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