@gntik-ai/blocks 0.6.0 → 0.8.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 (58) hide show
  1. package/dist/index.d.ts +202 -74
  2. package/dist/index.js +849 -283
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
  6. package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
  7. package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
  8. package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
  9. package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
  10. package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
  11. package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
  12. package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
  13. package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
  14. package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
  15. package/src/feedback/EmptyStates/block.meta.ts +1 -1
  16. package/src/feedback/EmptyStates/fixtures.ts +5 -0
  17. package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
  18. package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
  19. package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.additional.test.tsx +467 -0
  20. package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.tsx +175 -106
  21. package/src/forms/CredentialDisclosureDialog/IssueOnClick.test.tsx +56 -0
  22. package/src/forms/CredentialDisclosureDialog/SecretCopy.tsx +83 -0
  23. package/src/forms/CredentialDisclosureDialog/block.meta.ts +1 -1
  24. package/src/forms/CredentialDisclosureDialog/examples/Inline.tsx +19 -0
  25. package/src/forms/CredentialDisclosureDialog/examples/IssueOnClick.tsx +87 -0
  26. package/src/forms/CredentialDisclosureDialog/examples/MultiSecret.tsx +29 -0
  27. package/src/forms/CredentialDisclosureDialog/focus.ts +18 -0
  28. package/src/forms/CredentialDisclosureDialog/types.ts +5 -0
  29. package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
  30. package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
  31. package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
  32. package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
  33. package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
  34. package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
  35. package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
  36. package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
  37. package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
  38. package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
  39. package/src/tables/DataTable/DataTable.tsx +20 -12
  40. package/src/tables/DataTable/block.meta.ts +2 -1
  41. package/src/tables/DataTable/data-table-props.ts +27 -4
  42. package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
  43. package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
  44. package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
  45. package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
  46. package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
  47. package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
  48. package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
  49. package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
  50. package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
  51. package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
  52. package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
  53. package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
  54. package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
  55. package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
  56. package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
  57. package/src/tables/index.ts +4 -0
  58. package/src/test/CredentialDisclosureDialog.compatibility.tsx +30 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gntik-ai/blocks",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "gntik-ui blocks: page chrome, data display, tables, forms, feedback, billing, team and builder sections composed from @gntik-ai/ui.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "@gntik-ai/flow": "0.5.0",
41
41
  "@gntik-ai/icons": "0.5.0",
42
42
  "@gntik-ai/tokens": "0.2.1",
43
- "@gntik-ai/ui": "0.5.0",
43
+ "@gntik-ai/ui": "0.7.0",
44
44
  "lucide-react": "^1.48.0"
45
45
  },
46
46
  "devDependencies": {
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import SecretRevocation from './examples/SecretRevocation';
4
+ import { ServerFailure } from './examples/FailureStates';
5
+
6
+ it('documents optional justification and force revocation without changing typed-name safety', async () => {
7
+ const user = userEvent.setup();
8
+ render(<SecretRevocation />);
9
+ await user.click(screen.getByRole('button', { name: 'Revoke version' }));
10
+ expect(await screen.findByRole('textbox', { name: 'Justification' })).toHaveValue('');
11
+ expect(screen.getByRole('checkbox', { name: 'Force revocation' })).not.toBeChecked();
12
+ expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/last valid version/);
13
+ expect(screen.getByRole('button', { name: 'Revoke version' })).toBeDisabled();
14
+ await user.type(screen.getByRole('textbox', { name: 'Type version-7 to confirm' }), 'version-7');
15
+ expect(screen.getByRole('button', { name: 'Revoke version' })).toBeEnabled();
16
+ });
17
+
18
+ it('demonstrates a server failure with no duplicate live announcements', async () => {
19
+ const user = userEvent.setup();
20
+ render(<ServerFailure />);
21
+ await user.click(screen.getByRole('button', { name: 'Revoke version' }));
22
+ await user.type(await screen.findByRole('textbox', { name: 'Type version-7 to confirm' }), 'version-7{Enter}');
23
+ expect(await screen.findByRole('region', { name: 'Could not revoke version' })).toBeVisible();
24
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
25
+ });
@@ -0,0 +1,252 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ConfirmDestructive, type ConfirmDestructiveFailure } from './ConfirmDestructive';
4
+
5
+ function propContract() {
6
+ // @ts-expect-error validity must be a boolean
7
+ <ConfirmDestructive canConfirm="true" />;
8
+ // @ts-expect-error return focus requires an element ref
9
+ <ConfirmDestructive returnFocusRef={{ current: 'trigger' }} />;
10
+ // @ts-expect-error failures require a title
11
+ const missingTitle: ConfirmDestructiveFailure = { kind: 'server' };
12
+ // @ts-expect-error status is numeric
13
+ const stringStatus: ConfirmDestructiveFailure = { kind: 'server', title: 'Failed', status: '500' };
14
+ // @ts-expect-error network failures do not carry server metadata
15
+ const networkStatus: ConfirmDestructiveFailure = { kind: 'network', title: 'Offline', status: 500 };
16
+ void [missingTitle, stringStatus, networkStatus];
17
+ }
18
+ void propContract;
19
+
20
+ function deferred() {
21
+ let resolve!: () => void;
22
+ let reject!: (reason: unknown) => void;
23
+ const promise = new Promise<void>((yes, no) => {
24
+ resolve = yes;
25
+ reject = no;
26
+ });
27
+ return { promise, resolve, reject };
28
+ }
29
+
30
+ describe('ConfirmDestructive extensions', () => {
31
+ it('inserts a fresh alert for each identical synchronous failure', async () => {
32
+ const user = userEvent.setup();
33
+ render(
34
+ <ConfirmDestructive
35
+ defaultOpen
36
+ triggerLabel={null}
37
+ resourceName="db"
38
+ onConfirm={() => {
39
+ throw new Error('Failed');
40
+ }}
41
+ />,
42
+ );
43
+ await user.type(await screen.findByRole('textbox'), 'db{Enter}');
44
+ const first = await screen.findByRole('alert');
45
+ await user.click(screen.getByRole('button', { name: 'Delete project' }));
46
+ expect(await screen.findByRole('alert')).not.toBe(first);
47
+ });
48
+ it.each(['resolve', 'reject'] as const)(
49
+ 'ignores a stale %s after controlled close and reopen',
50
+ async (settlement) => {
51
+ const user = userEvent.setup();
52
+ const old = deferred();
53
+ const current = deferred();
54
+ const onConfirm = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise);
55
+ const onOpenChange = vi.fn();
56
+ const props = { triggerLabel: null, resourceName: 'db', onConfirm, onOpenChange };
57
+ const { rerender } = render(<ConfirmDestructive {...props} open />);
58
+ await user.type(await screen.findByRole('textbox'), 'db{Enter}');
59
+ rerender(<ConfirmDestructive {...props} open={false} />);
60
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
61
+ rerender(<ConfirmDestructive {...props} open />);
62
+ expect(await screen.findByRole('textbox')).toHaveValue('');
63
+ await user.type(screen.getByRole('textbox'), 'db{Enter}');
64
+ expect(onConfirm).toHaveBeenCalledTimes(2);
65
+ await act(async () => (settlement === 'resolve' ? old.resolve() : old.reject(new Error('Old failure'))));
66
+ expect(screen.getByRole('alertdialog')).toBeVisible();
67
+ expect(screen.getByRole('textbox')).toHaveValue('db');
68
+ expect(screen.getByRole('button', { name: 'Delete project' })).toHaveAttribute('aria-busy', 'true');
69
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
70
+ expect(onOpenChange).not.toHaveBeenCalled();
71
+ await act(async () => current.reject(new Error('Current failure')));
72
+ expect(await screen.findByRole('alert')).toHaveTextContent('Current failure');
73
+ },
74
+ );
75
+ it('keeps Retry mounted and focused while pending, guards every attempt, and announces identical failures once each', async () => {
76
+ const user = userEvent.setup();
77
+ const attempt = deferred();
78
+ const failure = { kind: 'server', title: 'Failed' };
79
+ const onConfirm = vi
80
+ .fn()
81
+ .mockRejectedValueOnce(failure)
82
+ .mockReturnValueOnce(attempt.promise)
83
+ .mockRejectedValue(failure);
84
+ const { rerender } = render(
85
+ <ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />,
86
+ );
87
+ await user.type(await screen.findByRole('textbox'), 'db{Enter}');
88
+ const retry = await screen.findByRole('button', { name: 'Try again' });
89
+ let inserted = 0;
90
+ const observer = new MutationObserver((records) => {
91
+ for (const record of records)
92
+ for (const node of record.addedNodes) {
93
+ if (node instanceof HTMLElement && node.matches('[role="alert"]')) inserted++;
94
+ }
95
+ });
96
+ observer.observe(screen.getByRole('alertdialog'), { childList: true, subtree: true });
97
+ await user.click(retry);
98
+ expect(retry).toBeInTheDocument();
99
+ expect(retry).toHaveFocus();
100
+ expect(retry).toHaveAttribute('aria-busy', 'true');
101
+ expect(screen.getByRole('textbox')).toHaveAttribute('readonly');
102
+ expect(screen.getByRole('textbox')).toBeEnabled();
103
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
104
+ fireEvent.click(retry);
105
+ fireEvent.submit(screen.getByRole('textbox').closest('form')!);
106
+ expect(onConfirm).toHaveBeenCalledTimes(2);
107
+ await act(async () => attempt.reject(failure));
108
+ expect(await screen.findByRole('alert')).toHaveTextContent('Failed');
109
+ expect(retry).toHaveFocus();
110
+ await user.click(retry);
111
+ await waitFor(() => expect(inserted).toBe(2));
112
+ await user.clear(screen.getByRole('textbox'));
113
+ await user.click(retry);
114
+ expect(onConfirm).toHaveBeenCalledTimes(3);
115
+ await user.type(screen.getByRole('textbox'), 'db');
116
+ rerender(
117
+ <ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm={false} onConfirm={onConfirm} />,
118
+ );
119
+ await user.click(retry);
120
+ expect(onConfirm).toHaveBeenCalledTimes(3);
121
+ observer.disconnect();
122
+ });
123
+
124
+ it.each([
125
+ { kind: 'server', title: 'Failed', message: { error: 'x' }, status: '500', requestId: 7 },
126
+ { kind: 'server', title: 'Failed', status: Infinity },
127
+ { kind: 'network', title: 'Failed' },
128
+ ])('drops malformed or absent optional fields: %j', async (failure) => {
129
+ render(
130
+ <ConfirmDestructive
131
+ defaultOpen
132
+ triggerLabel={null}
133
+ resourceName="db"
134
+ onConfirm={() => Promise.reject(failure)}
135
+ />,
136
+ );
137
+ await userEvent.setup().type(await screen.findByRole('textbox'), 'db{Enter}');
138
+ const panel = await screen.findByRole('region', { name: 'Failed' });
139
+ expect(panel).not.toHaveTextContent(/500|Infinity|Request ID|service didn’t respond/);
140
+ expect(screen.getByRole('alert')).toHaveTextContent(/^Failed$/);
141
+ });
142
+
143
+ it.each([7, '', ' '])('uses generic fallback for invalid title %j', async (title) => {
144
+ render(
145
+ <ConfirmDestructive
146
+ defaultOpen
147
+ triggerLabel={null}
148
+ resourceName="db"
149
+ onConfirm={() => Promise.reject({ kind: 'server', title })}
150
+ />,
151
+ );
152
+ await userEvent.setup().type(await screen.findByRole('textbox'), 'db{Enter}');
153
+ expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Try again.');
154
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
155
+ });
156
+ it.each(['no-access', 'not-found'] as const)('replaces interactive content with %s and only Close', async (kind) => {
157
+ const user = userEvent.setup();
158
+ render(
159
+ <ConfirmDestructive
160
+ defaultOpen
161
+ triggerLabel={null}
162
+ resourceName="db"
163
+ onConfirm={() => Promise.reject({ kind, title: 'Unavailable', description: 'Return to the list.' })}
164
+ >
165
+ <label>
166
+ Justification
167
+ <textarea />
168
+ </label>
169
+ </ConfirmDestructive>,
170
+ );
171
+ await user.type(await screen.findByRole('textbox', { name: 'Type db to confirm' }), 'db{Enter}');
172
+ expect(await screen.findByRole('heading', { name: 'Unavailable' })).toBeVisible();
173
+ expect(screen.getByText('Return to the list.')).toBeVisible();
174
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
175
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
176
+ expect(screen.getAllByRole('button')).toHaveLength(1);
177
+ expect(screen.getByRole('button', { name: 'Close' })).toBeVisible();
178
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
179
+ expect(screen.getByRole('alert')).toHaveTextContent('Unavailable Return to the list.');
180
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
181
+ });
182
+ it.each(['server', 'network'] as const)(
183
+ 'preserves fields and renders %s failures without sample copy',
184
+ async (kind) => {
185
+ const user = userEvent.setup();
186
+ render(
187
+ <ConfirmDestructive
188
+ defaultOpen
189
+ triggerLabel={null}
190
+ resourceName="db"
191
+ onConfirm={() =>
192
+ Promise.reject({
193
+ kind,
194
+ title: 'Action failed',
195
+ message: 'Try later.',
196
+ status: 500,
197
+ requestId: 'req_actual',
198
+ })
199
+ }
200
+ >
201
+ <label>
202
+ Justification
203
+ <textarea />
204
+ </label>
205
+ </ConfirmDestructive>,
206
+ );
207
+ await user.type(await screen.findByRole('textbox', { name: 'Justification' }), 'Keep this');
208
+ await user.type(screen.getByRole('textbox', { name: 'Type db to confirm' }), 'db{Enter}');
209
+ expect(await screen.findByRole('region', { name: 'Action failed' })).toHaveTextContent('Try later.');
210
+ expect(screen.getAllByRole('alert')).toHaveLength(1);
211
+ expect(screen.getByRole('alert')).toHaveTextContent('Action failed Try later.');
212
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
213
+ expect(screen.getByRole('textbox', { name: 'Justification' })).toHaveValue('Keep this');
214
+ expect(screen.getByRole('textbox', { name: 'Type db to confirm' })).toHaveValue('db');
215
+ expect(screen.queryByText('Contact support')).not.toBeInTheDocument();
216
+ expect(screen.queryByText('Technical details')).not.toBeInTheDocument();
217
+ expect(screen.queryByText('req_8f2c41d07a9b')).not.toBeInTheDocument();
218
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeEnabled();
219
+ if (kind === 'server') {
220
+ expect(screen.getByText('500')).toBeVisible();
221
+ expect(screen.getByText('req_actual')).toBeVisible();
222
+ } else {
223
+ expect(screen.queryByText('500')).not.toBeInTheDocument();
224
+ expect(screen.queryByText('req_actual')).not.toBeInTheDocument();
225
+ }
226
+ },
227
+ );
228
+ it('places extra fields before the typed field, outside the description, and gates confirmation', async () => {
229
+ const user = userEvent.setup();
230
+ const onConfirm = vi.fn();
231
+ const { rerender } = render(
232
+ <ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm={false} onConfirm={onConfirm}>
233
+ <label>
234
+ Justification
235
+ <textarea />
236
+ </label>
237
+ </ConfirmDestructive>,
238
+ );
239
+ const extra = await screen.findByRole('textbox', { name: 'Justification' });
240
+ const typed = screen.getByRole('textbox', { name: 'Type db to confirm' });
241
+ const dialog = screen.getByRole('alertdialog');
242
+ expect(extra.closest('form')).toBe(typed.closest('form'));
243
+ expect(extra.compareDocumentPosition(typed) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
244
+ expect(document.getElementById(dialog.getAttribute('aria-describedby')!)?.contains(extra)).toBe(false);
245
+ await user.type(typed, 'db{Enter}');
246
+ expect(screen.getByRole('button', { name: 'Delete project' })).toBeDisabled();
247
+ expect(onConfirm).not.toHaveBeenCalled();
248
+ rerender(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />);
249
+ await user.click(screen.getByRole('button', { name: 'Delete project' }));
250
+ expect(onConfirm).toHaveBeenCalledTimes(1);
251
+ });
252
+ });
@@ -0,0 +1,178 @@
1
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { useRef, useState } from 'react';
4
+ import { I18nProvider } from '@gntik-ai/ui';
5
+ import { ConfirmDestructive } from './ConfirmDestructive';
6
+
7
+ function deferred() {
8
+ let resolve!: () => void;
9
+ let reject!: (reason: unknown) => void;
10
+ const promise = new Promise<void>((yes, no) => {
11
+ resolve = yes;
12
+ reject = no;
13
+ });
14
+ return { promise, resolve, reject };
15
+ }
16
+
17
+ describe('ConfirmDestructive focus', () => {
18
+ it('initially focuses the typed input even with a focusable slot and preserves focus during Enter submission', async () => {
19
+ const attempt = deferred();
20
+ render(
21
+ <ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={() => attempt.promise}>
22
+ <label>
23
+ Justification
24
+ <textarea />
25
+ </label>
26
+ </ConfirmDestructive>,
27
+ );
28
+ const typed = await screen.findByRole('textbox', { name: 'Type db to confirm' });
29
+ await waitFor(() => expect(typed).toHaveFocus());
30
+ await userEvent.setup().type(typed, 'db{Enter}');
31
+ expect(typed).toHaveFocus();
32
+ await act(async () => attempt.reject(new Error('Failed')));
33
+ expect(typed).toHaveFocus();
34
+ });
35
+
36
+ it.each(['no-access', 'not-found'] as const)('moves focus to Close after %s removes the input', async (kind) => {
37
+ render(
38
+ <ConfirmDestructive
39
+ defaultOpen
40
+ triggerLabel={null}
41
+ resourceName="db"
42
+ onConfirm={() => Promise.reject({ kind, title: 'Unavailable' })}
43
+ />,
44
+ );
45
+ await userEvent.setup().type(await screen.findByRole('textbox'), 'db{Enter}');
46
+ expect(await screen.findByRole('button', { name: 'Close' })).toHaveFocus();
47
+ expect(screen.getByRole('alert')).toHaveTextContent(/^Unavailable$/);
48
+ });
49
+
50
+ it('moves from a removed Retry to the input on generic failure', async () => {
51
+ const user = userEvent.setup();
52
+ const onConfirm = vi
53
+ .fn()
54
+ .mockRejectedValueOnce({ kind: 'server', title: 'Failed' })
55
+ .mockRejectedValue(new Error('Generic'));
56
+ render(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />);
57
+ await user.type(await screen.findByRole('textbox'), 'db{Enter}');
58
+ await user.click(await screen.findByRole('button', { name: 'Try again' }));
59
+ expect(await screen.findByRole('alert')).toHaveTextContent('Generic');
60
+ expect(screen.getByRole('textbox')).toHaveFocus();
61
+ });
62
+
63
+ it('moves from a removed request-id copy button to Retry', async () => {
64
+ const user = userEvent.setup();
65
+ const attempt = deferred();
66
+ const onConfirm = vi
67
+ .fn()
68
+ .mockRejectedValueOnce({ kind: 'server', title: 'Failed', requestId: 'req_1' })
69
+ .mockReturnValue(attempt.promise);
70
+ render(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />);
71
+ await user.type(await screen.findByRole('textbox'), 'db{Enter}');
72
+ await user.click(await screen.findByRole('button', { name: 'Try again' }));
73
+ act(() => screen.getByRole('button', { name: 'Copy request ID' }).focus());
74
+ await act(async () => attempt.reject({ kind: 'server', title: 'Still failed' }));
75
+ expect(await screen.findByRole('button', { name: 'Try again' })).toHaveFocus();
76
+ });
77
+
78
+ it('moves a newly disabled Confirm to the input without moving unrelated focus', async () => {
79
+ const user = userEvent.setup();
80
+ const { rerender } = render(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" />);
81
+ await user.type(await screen.findByRole('textbox'), 'db');
82
+ act(() => screen.getByRole('button', { name: 'Delete project' }).focus());
83
+ rerender(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm={false} />);
84
+ expect(screen.getByRole('textbox')).toHaveFocus();
85
+ act(() => screen.getByRole('button', { name: 'Cancel' }).focus());
86
+ rerender(<ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" canConfirm />);
87
+ expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
88
+ });
89
+
90
+ it('skips a busy Retry when a slot control becomes disabled', async () => {
91
+ const user = userEvent.setup();
92
+ const attempt = deferred();
93
+ const onConfirm = vi
94
+ .fn()
95
+ .mockRejectedValueOnce({ kind: 'network', title: 'Offline' })
96
+ .mockReturnValue(attempt.promise);
97
+ const props = { defaultOpen: true, triggerLabel: null, resourceName: 'db', onConfirm };
98
+ const { rerender } = render(
99
+ <ConfirmDestructive {...props}>
100
+ <button>Extra control</button>
101
+ </ConfirmDestructive>,
102
+ );
103
+ await user.type(await screen.findByRole('textbox'), 'db{Enter}');
104
+ await user.click(await screen.findByRole('button', { name: 'Try again' }));
105
+ act(() => screen.getByRole('button', { name: 'Extra control' }).focus());
106
+ rerender(
107
+ <ConfirmDestructive {...props}>
108
+ <button disabled>Extra control</button>
109
+ </ConfirmDestructive>,
110
+ );
111
+ expect(screen.getByRole('textbox')).toHaveFocus();
112
+ await act(async () => attempt.reject(new Error('Failed')));
113
+ });
114
+ });
115
+
116
+ function FocusReturn({
117
+ target,
118
+ outcome,
119
+ }: {
120
+ target: 'internal' | 'ref' | 'opener';
121
+ outcome: 'Cancel' | 'Close' | 'Escape' | 'success';
122
+ }) {
123
+ const [open, setOpen] = useState(false);
124
+ const returnFocusRef = useRef<HTMLButtonElement>(null);
125
+ return (
126
+ <>
127
+ <button ref={returnFocusRef}>Return here</button>
128
+ <button onClick={() => setOpen(true)}>Open confirmation</button>
129
+ <ConfirmDestructive
130
+ open={open}
131
+ onOpenChange={setOpen}
132
+ triggerLabel={target === 'internal' ? 'Internal trigger' : null}
133
+ returnFocusRef={target === 'ref' ? returnFocusRef : undefined}
134
+ resourceName="db"
135
+ onConfirm={() =>
136
+ outcome === 'Close' ? Promise.reject({ kind: 'not-found', title: 'Gone' }) : Promise.resolve()
137
+ }
138
+ />
139
+ </>
140
+ );
141
+ }
142
+
143
+ for (const target of ['internal', 'ref', 'opener'] as const) {
144
+ it.each(['Cancel', 'Close', 'Escape', 'success'] as const)(`returns focus to ${target} after %s`, async (outcome) => {
145
+ const user = userEvent.setup();
146
+ render(<FocusReturn target={target} outcome={outcome} />);
147
+ const opener = screen.getByRole('button', {
148
+ name: target === 'internal' ? 'Internal trigger' : 'Open confirmation',
149
+ });
150
+ const destination = target === 'ref' ? screen.getByRole('button', { name: 'Return here' }) : opener;
151
+ await user.click(opener);
152
+ const typed = await screen.findByRole('textbox');
153
+ if (outcome === 'success' || outcome === 'Close') {
154
+ await user.type(typed, 'db{Enter}');
155
+ if (outcome === 'Close') await user.click(await screen.findByRole('button', { name: 'Close' }));
156
+ } else if (outcome === 'Escape') await user.keyboard('{Escape}');
157
+ else await user.click(screen.getByRole('button', { name: 'Cancel' }));
158
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
159
+ await waitFor(() => expect(destination).toHaveFocus());
160
+ });
161
+ }
162
+
163
+ it('uses the existing Spanish Cancel, Retry and Close messages', async () => {
164
+ const user = userEvent.setup();
165
+ const onConfirm = vi
166
+ .fn()
167
+ .mockRejectedValueOnce({ kind: 'network', title: 'Offline' })
168
+ .mockRejectedValue({ kind: 'no-access', title: 'Unavailable' });
169
+ render(
170
+ <I18nProvider locale="es">
171
+ <ConfirmDestructive defaultOpen triggerLabel={null} resourceName="db" onConfirm={onConfirm} />
172
+ </I18nProvider>,
173
+ );
174
+ expect(await screen.findByRole('button', { name: 'Cancelar' })).toBeVisible();
175
+ await user.type(screen.getByRole('textbox'), 'db{Enter}');
176
+ await user.click(await screen.findByRole('button', { name: 'Reintentar' }));
177
+ expect(await screen.findByRole('button', { name: 'Cerrar' })).toHaveFocus();
178
+ });