@gntik-ai/blocks 0.7.0 → 0.9.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 (29) hide show
  1. package/dist/index.d.ts +34 -7
  2. package/dist/index.js +283 -109
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/builders/LogViewer/LogViewer.test.tsx +11 -0
  6. package/src/builders/LogViewer/LogViewer.tsx +28 -22
  7. package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.additional.test.tsx +467 -0
  8. package/src/forms/CredentialDisclosureDialog/CredentialDisclosureDialog.tsx +175 -106
  9. package/src/forms/CredentialDisclosureDialog/IssueOnClick.test.tsx +56 -0
  10. package/src/forms/CredentialDisclosureDialog/SecretCopy.tsx +83 -0
  11. package/src/forms/CredentialDisclosureDialog/block.meta.ts +1 -1
  12. package/src/forms/CredentialDisclosureDialog/examples/Inline.tsx +19 -0
  13. package/src/forms/CredentialDisclosureDialog/examples/IssueOnClick.tsx +87 -0
  14. package/src/forms/CredentialDisclosureDialog/examples/MultiSecret.tsx +29 -0
  15. package/src/forms/CredentialDisclosureDialog/focus.ts +18 -0
  16. package/src/forms/CredentialDisclosureDialog/types.ts +5 -0
  17. package/src/page-chrome/PageHeader/PageHeader.test.tsx +18 -0
  18. package/src/page-chrome/PageHeader/PageHeader.tsx +3 -1
  19. package/src/page-chrome/PageHeader/block.meta.ts +1 -1
  20. package/src/page-chrome/PageHeader/examples/Spanish.tsx +11 -0
  21. package/src/tables/DataTable/DataTable.labels.test.tsx +2 -0
  22. package/src/tables/DataTable/DataTable.locale.test.tsx +153 -0
  23. package/src/tables/DataTable/DataTable.server-pagination.test.tsx +1 -1
  24. package/src/tables/DataTable/DataTable.test.tsx +16 -0
  25. package/src/tables/DataTable/block.meta.ts +2 -0
  26. package/src/tables/DataTable/data-table-row.tsx +18 -2
  27. package/src/tables/DataTable/data-table-utils.ts +18 -4
  28. package/src/tables/DataTable/labels.ts +2 -0
  29. 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.7.0",
3
+ "version": "0.9.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.7.0",
43
+ "@gntik-ai/ui": "0.9.0",
44
44
  "lucide-react": "^1.48.0"
45
45
  },
46
46
  "devDependencies": {
@@ -95,3 +95,14 @@ describe('LogViewer', () => {
95
95
  expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument();
96
96
  });
97
97
  });
98
+
99
+ it.each([
100
+ ['Función', 'funcion', 'Función'],
101
+ ['İstanbul', 'stan', 'stan'],
102
+ ])('keeps and marks the original substring of %s', async (message, query, marked) => {
103
+ const user = userEvent.setup();
104
+ render(<LogViewer lines={[{ ...makeLogLines(1)[0]!, message, source: undefined }]} />);
105
+ await user.type(screen.getByRole('searchbox', { name: 'Search logs' }), query);
106
+ expect(renderedLines()).toHaveLength(1);
107
+ expect([...screen.getByRole('log').querySelectorAll('mark')].map((mark) => mark.textContent)).toEqual([marked]);
108
+ });
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState, type ReactNode, type UIEvent } from 'react';
2
2
  import { Check, Copy, Eye, Search } from '@gntik-ai/icons';
3
- import { IconButton, Input, Switch, Toggle, ToggleGroup, cn } from '@gntik-ai/ui';
3
+ import { IconButton, Input, Switch, Toggle, ToggleGroup, cn, foldText, matchRanges, useI18n } from '@gntik-ai/ui';
4
4
  import { logLines as defaultLines, type LogLevel, type LogLine } from './fixtures';
5
5
 
6
6
  export type { LogLevel, LogLine } from './fixtures';
@@ -43,24 +43,19 @@ export interface LogViewerProps {
43
43
  }
44
44
 
45
45
  /** Wraps case-insensitive matches of `query` in <mark>. */
46
- function highlight(text: string, query: string): ReactNode {
47
- if (!query) return text;
48
- const lower = text.toLowerCase();
49
- const q = query.toLowerCase();
46
+ function highlight(text: string, query: string, locale?: string): ReactNode {
50
47
  const parts: ReactNode[] = [];
51
48
  let from = 0;
52
- let at = lower.indexOf(q, from);
53
- while (at !== -1) {
54
- if (at > from) parts.push(text.slice(from, at));
49
+ for (const [start, end] of matchRanges(text, query, { locale })) {
50
+ parts.push(text.slice(from, start));
55
51
  parts.push(
56
- <mark key={at} className="rounded-sm bg-warning/30 text-foreground">
57
- {text.slice(at, at + q.length)}
52
+ <mark key={start} className="rounded-sm bg-warning/30 text-foreground">
53
+ {text.slice(start, end)}
58
54
  </mark>,
59
55
  );
60
- from = at + q.length;
61
- at = lower.indexOf(q, from);
56
+ from = end;
62
57
  }
63
- if (from < text.length) parts.push(text.slice(from));
58
+ parts.push(text.slice(from));
64
59
  return parts;
65
60
  }
66
61
 
@@ -88,6 +83,7 @@ export function LogViewer({
88
83
  titleAs: TitleTag = 'h3',
89
84
  className,
90
85
  }: LogViewerProps) {
86
+ const { locale } = useI18n();
91
87
  const titleId = useId();
92
88
  const viewport = useRef<HTMLDivElement>(null);
93
89
  const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -103,14 +99,24 @@ export function LogViewer({
103
99
  return c;
104
100
  }, [lines]);
105
101
 
102
+ const foldedLines = useMemo(
103
+ () =>
104
+ lines.map((line) => ({
105
+ line,
106
+ message: foldText(line.message, locale),
107
+ source: foldText(line.source ?? '', locale),
108
+ })),
109
+ [lines, locale],
110
+ );
106
111
  const visible = useMemo(() => {
107
- const q = query.trim().toLowerCase();
108
- return lines.filter(
109
- (l) =>
110
- levels.includes(l.level) &&
111
- (!q || l.message.toLowerCase().includes(q) || (l.source ?? '').toLowerCase().includes(q)),
112
- );
113
- }, [lines, levels, query]);
112
+ const q = foldText(query.trim(), locale);
113
+ return foldedLines
114
+ .filter(
115
+ ({ line, message, source }) =>
116
+ levels.includes(line.level) && (!q || message.indexOf(q) !== -1 || source.indexOf(q) !== -1),
117
+ )
118
+ .map(({ line }) => line);
119
+ }, [foldedLines, levels, query, locale]);
114
120
 
115
121
  const total = visible.length * rowHeight;
116
122
  const maxScroll = Math.max(0, total - height);
@@ -213,11 +219,11 @@ export function LogViewer({
213
219
  </span>
214
220
  {line.source && (
215
221
  <span className="hidden shrink-0 text-muted-foreground sm:inline">
216
- [{highlight(line.source, query.trim())}]
222
+ [{highlight(line.source, query, locale)}]
217
223
  </span>
218
224
  )}
219
225
  <span className="min-w-0 flex-1 truncate text-foreground">
220
- {highlight(line.message, query.trim())}
226
+ {highlight(line.message, query, locale)}
221
227
  </span>
222
228
  {onSelectLine && (
223
229
  <IconButton
@@ -0,0 +1,467 @@
1
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { I18nProvider } from '@gntik-ai/ui';
4
+ import { CredentialDisclosureDialog } from './CredentialDisclosureDialog';
5
+ import { storedDisclosure } from './fixtures';
6
+ import { expectNoAxeViolations } from '../../test/a11y';
7
+
8
+ const additionalSecrets = [
9
+ { id: 'dsn', label: 'DSN', value: 'synthetic_dsn_value' },
10
+ { id: 'password', label: 'Password', value: 'synthetic_password_value' },
11
+ ];
12
+
13
+ it.each([
14
+ ['en', 'DSN: Copied'],
15
+ ['es', 'DSN: Copiado'],
16
+ ['override', 'Saved DSN'],
17
+ ])('masks independent additional secrets and copies with label-only feedback (%s)', async (locale, announcement) => {
18
+ const user = userEvent.setup();
19
+ const writeText = vi.fn().mockResolvedValue(undefined);
20
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
21
+ const onCopy = vi.fn();
22
+ render(
23
+ <I18nProvider locale={locale!}>
24
+ <CredentialDisclosureDialog
25
+ disclosure={storedDisclosure}
26
+ onClose={() => {}}
27
+ additionalSecrets={additionalSecrets}
28
+ showLabel="Reveal"
29
+ hideLabel="Hide"
30
+ copyLabel="Copy"
31
+ onCopy={onCopy}
32
+ successAnnouncement="Unused single-secret feedback"
33
+ copiedAnnouncementFor={locale === 'override' ? (label) => `Saved ${label}` : undefined}
34
+ />
35
+ </I18nProvider>,
36
+ );
37
+ const dsn = await screen.findByRole('group', { name: 'DSN' });
38
+ const password = screen.getByRole('group', { name: 'Password' });
39
+ expect(within(dsn).getByRole('textbox')).not.toHaveValue(additionalSecrets[0]!.value);
40
+ await user.click(within(dsn).getByRole('button', { name: 'Reveal' }));
41
+ expect(within(dsn).getByRole('textbox')).toHaveValue(additionalSecrets[0]!.value);
42
+ expect(within(password).getByRole('button', { name: 'Reveal' })).toHaveAttribute('aria-pressed', 'false');
43
+ await user.click(within(dsn).getByRole('button', { name: 'Copy' }));
44
+ expect(writeText).toHaveBeenCalledWith('synthetic_dsn_value');
45
+ expect(onCopy).toHaveBeenCalledExactlyOnceWith('dsn');
46
+ expect(within(dsn).getByRole('status').textContent).toBe(announcement);
47
+ expect(within(dsn).getByRole('textbox').closest('[data-kit-redact]')).not.toBeNull();
48
+ expect(within(dsn).getByRole('status')).not.toHaveTextContent('synthetic_');
49
+ });
50
+
51
+ it('masks a fresh primary for the session and redacts the reveal slot, keeping its warning', async () => {
52
+ const user = userEvent.setup();
53
+ const props = { disclosure: { ...storedDisclosure, variant: 'fresh' as const }, onClose: vi.fn() };
54
+ const { rerender } = render(
55
+ <CredentialDisclosureDialog {...props} maskFresh revealContent={<p>synthetic_slot_value</p>} />,
56
+ );
57
+ await screen.findByRole('dialog');
58
+ expect(screen.getByRole('button', { name: 'Show secret' })).toHaveAttribute('aria-pressed', 'false');
59
+ expect(screen.getByRole('alert')).toBeInTheDocument();
60
+ expect(screen.getByText('synthetic_slot_value').closest('[data-kit-redact]')).not.toBeNull();
61
+ rerender(<CredentialDisclosureDialog {...props} maskFresh={false} />);
62
+ expect(screen.getByRole('button', { name: 'Show secret' })).toBeInTheDocument();
63
+ await user.click(screen.getByRole('button', { name: 'Show secret' }));
64
+ expect(screen.getByRole('textbox').closest('[data-kit-redact]')).not.toBeNull();
65
+ });
66
+
67
+ it.each(['stored', 'masked-fresh', 'fresh'] as const)(
68
+ 'dismisses retained %s props and resets a cleared or changed session in both presentations',
69
+ async (kind) => {
70
+ const user = userEvent.setup();
71
+ for (const presentation of ['dialog', 'inline'] as const) {
72
+ const props = {
73
+ disclosure: { ...storedDisclosure, variant: kind === 'stored' ? ('stored' as const) : ('fresh' as const) },
74
+ maskFresh: kind === 'masked-fresh',
75
+ presentation,
76
+ onClose: vi.fn(),
77
+ };
78
+ const { rerender, unmount } = render(<CredentialDisclosureDialog {...props} />);
79
+ await screen.findByRole(presentation === 'dialog' ? 'dialog' : 'region', { name: 'Credential secret' });
80
+ if (kind !== 'fresh') await user.click(screen.getByRole('button', { name: 'Show secret' }));
81
+ await user.click(screen.getByRole('button', { name: 'Close' }));
82
+ expect(props.onClose).toHaveBeenCalledOnce();
83
+ rerender(<CredentialDisclosureDialog {...props} />);
84
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
85
+ expect(document.body.textContent).not.toContain(storedDisclosure.secret);
86
+ rerender(<CredentialDisclosureDialog {...props} disclosure={null} />);
87
+ rerender(<CredentialDisclosureDialog {...props} />);
88
+ await screen.findByRole(presentation === 'dialog' ? 'dialog' : 'region', { name: 'Credential secret' });
89
+ if (kind !== 'fresh') {
90
+ expect(screen.getByRole('button', { name: 'Show secret' })).toHaveAttribute('aria-pressed', 'false');
91
+ await user.click(screen.getByRole('button', { name: 'Show secret' }));
92
+ } else expect(screen.getByRole('region', { name: 'Secret' })).toHaveTextContent(storedDisclosure.secret);
93
+ rerender(
94
+ <CredentialDisclosureDialog {...props} disclosure={{ ...props.disclosure, secret: 'synthetic_replaced' }} />,
95
+ );
96
+ if (kind !== 'fresh')
97
+ expect(screen.getByRole('button', { name: 'Show secret' })).toHaveAttribute('aria-pressed', 'false');
98
+ unmount();
99
+ }
100
+ },
101
+ );
102
+
103
+ it('renders an inline named section without moving focus, trapping Tab or handling Escape; presentation is fixed per opening', async () => {
104
+ const user = userEvent.setup();
105
+ const outside = document.createElement('button');
106
+ document.body.append(outside);
107
+ outside.focus();
108
+ const onClose = vi.fn();
109
+ const props = {
110
+ disclosure: { ...storedDisclosure, variant: 'fresh' as const },
111
+ onClose,
112
+ title: 'Inline credentials',
113
+ presentation: 'inline' as const,
114
+ };
115
+ const { rerender, unmount } = render(<CredentialDisclosureDialog {...props} />);
116
+ const section = screen.getByRole('region', { name: 'Inline credentials' });
117
+ expect(section.tagName).toBe('SECTION');
118
+ expect(outside).toHaveFocus();
119
+ expect(screen.getByRole('note')).toBeInTheDocument();
120
+ await user.keyboard('{Escape}');
121
+ expect(onClose).not.toHaveBeenCalled();
122
+ rerender(<CredentialDisclosureDialog {...props} presentation="dialog" />);
123
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
124
+ const close = screen.getByRole('button', { name: 'Close' });
125
+ close.focus();
126
+ await user.tab();
127
+ expect(section.contains(document.activeElement)).toBe(false);
128
+ await user.click(close);
129
+ expect(outside).toHaveFocus();
130
+ unmount();
131
+ outside.remove();
132
+ });
133
+
134
+ it.each(['inline', 'dialog'] as const)(
135
+ 'preserves surviving focus and recovers removed slot/session focus (%s)',
136
+ async (presentation) => {
137
+ const outside = document.createElement('button');
138
+ document.body.append(outside);
139
+ const props = {
140
+ disclosure: storedDisclosure,
141
+ onClose: vi.fn(),
142
+ presentation,
143
+ revealContent: <button>Slot action</button>,
144
+ };
145
+ const { rerender, unmount } = render(<CredentialDisclosureDialog {...props} />);
146
+ await screen.findByRole(presentation === 'dialog' ? 'dialog' : 'region', { name: 'Credential secret' });
147
+ const copy = screen.getByRole('button', { name: 'Copy secret' });
148
+ copy.focus();
149
+ rerender(<CredentialDisclosureDialog {...props} revealContent={<button>Replacement slot</button>} />);
150
+ expect(copy).toHaveFocus();
151
+ screen.getByRole('button', { name: 'Replacement slot' }).focus();
152
+ rerender(<CredentialDisclosureDialog {...props} revealContent={null} />);
153
+ const fallback = () =>
154
+ presentation === 'dialog'
155
+ ? screen.getByRole('dialog')
156
+ : screen.getByRole('heading', { name: 'Credential secret' });
157
+ expect(fallback()).toHaveFocus();
158
+ screen.getByRole('button', { name: 'Show secret' }).focus();
159
+ rerender(
160
+ <CredentialDisclosureDialog {...props} disclosure={{ ...storedDisclosure, secret: 'synthetic_next_session' }} />,
161
+ );
162
+ await waitFor(() => expect(fallback()).toHaveFocus());
163
+ unmount();
164
+ outside.remove();
165
+ },
166
+ );
167
+
168
+ it('leaves outside focus alone during inline updates and returns focused inline unmounts to a live fallback', () => {
169
+ const outside = document.createElement('button');
170
+ document.body.append(outside);
171
+ outside.focus();
172
+ const props = {
173
+ disclosure: storedDisclosure,
174
+ presentation: 'inline' as const,
175
+ onClose: vi.fn(),
176
+ resolveReturnFocus: () => null,
177
+ fallbackFocus: () => outside,
178
+ };
179
+ const { rerender, unmount } = render(<CredentialDisclosureDialog {...props} revealContent={<button>Slot</button>} />);
180
+ rerender(
181
+ <CredentialDisclosureDialog
182
+ {...props}
183
+ revealContent={null}
184
+ disclosure={{ ...storedDisclosure, secret: 'synthetic_changed' }}
185
+ />,
186
+ );
187
+ expect(outside).toHaveFocus();
188
+ screen.getByRole('button', { name: 'Copy secret' }).focus();
189
+ unmount();
190
+ expect(outside).toHaveFocus();
191
+ outside.remove();
192
+ });
193
+
194
+ it('filters invalid ids and first duplicate wins; changed additional values reset every mask', async () => {
195
+ const user = userEvent.setup();
196
+ const entries = [
197
+ { id: '', label: 'Empty', value: 'bad' },
198
+ { id: 'secret', label: 'Reserved', value: 'bad' },
199
+ ...additionalSecrets,
200
+ { id: 'dsn', label: 'Duplicate', value: 'bad' },
201
+ ];
202
+ const props = { disclosure: storedDisclosure, onClose: vi.fn(), additionalSecrets: entries };
203
+ const { rerender } = render(<CredentialDisclosureDialog {...props} />);
204
+ await screen.findByRole('dialog');
205
+ expect(screen.getAllByRole('group').map((node) => node.getAttribute('aria-label'))).toEqual([
206
+ 'Secret',
207
+ 'DSN',
208
+ 'Password',
209
+ ]);
210
+ await user.click(within(screen.getByRole('group', { name: 'DSN' })).getByRole('button', { name: 'Show secret' }));
211
+ rerender(
212
+ <CredentialDisclosureDialog
213
+ {...props}
214
+ additionalSecrets={[{ ...additionalSecrets[0]!, value: 'synthetic_changed_dsn' }]}
215
+ />,
216
+ );
217
+ for (const button of screen.getAllByRole('button', { name: 'Show secret' }))
218
+ expect(button).toHaveAttribute('aria-pressed', 'false');
219
+ });
220
+
221
+ function observeAnnouncements() {
222
+ const messages: string[] = [];
223
+ const observer = new MutationObserver((records) => {
224
+ for (const record of records) {
225
+ const region =
226
+ record.target instanceof Element
227
+ ? record.target.closest('[data-live-announcer]')
228
+ : record.target.parentElement?.closest('[data-live-announcer]');
229
+ if (region?.textContent) messages.push(region.textContent.trim());
230
+ }
231
+ });
232
+ observer.observe(document.body, { subtree: true, childList: true, characterData: true });
233
+ return { messages, stop: () => observer.disconnect() };
234
+ }
235
+
236
+ it.each(['stored', 'fresh', 'masked-fresh'] as const)(
237
+ 'keeps a pending single-secret copy interactive without busy feedback (%s)',
238
+ async (kind) => {
239
+ const user = userEvent.setup();
240
+ const settlements: (() => void)[] = [];
241
+ const writeText = vi.fn(() => new Promise<void>((resolve) => settlements.push(resolve)));
242
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
243
+ const onCopy = vi.fn();
244
+ render(
245
+ <CredentialDisclosureDialog
246
+ disclosure={{ ...storedDisclosure, variant: kind === 'stored' ? 'stored' : 'fresh' }}
247
+ maskFresh={kind === 'masked-fresh'}
248
+ onClose={() => {}}
249
+ onCopy={onCopy}
250
+ />,
251
+ );
252
+ const button = await screen.findByRole('button', { name: 'Copy secret' });
253
+ const observation = observeAnnouncements();
254
+ await user.click(button);
255
+ expect(writeText).toHaveBeenCalledExactlyOnceWith(storedDisclosure.secret);
256
+ expect(button).not.toHaveAttribute('aria-busy');
257
+ expect(button).toBeEnabled();
258
+ await user.click(button);
259
+ expect(writeText).toHaveBeenCalledTimes(2);
260
+ expect(observation.messages).toEqual([]);
261
+ expect(onCopy).not.toHaveBeenCalled();
262
+ await act(async () => settlements[0]!());
263
+ await act(async () => settlements[1]!());
264
+ expect(observation.messages).toEqual(['Secret copied to clipboard', 'Secret copied to clipboard']);
265
+ expect(onCopy.mock.calls).toEqual([['secret'], ['secret']]);
266
+ observation.stop();
267
+ },
268
+ );
269
+
270
+ it('announces each settled copy once, including repeats, failures and deferred attempts; serializes pending activations', async () => {
271
+ const user = userEvent.setup();
272
+ let settle!: () => void;
273
+ const writeText = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
274
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
275
+ const onCopy = vi.fn();
276
+ render(
277
+ <CredentialDisclosureDialog
278
+ disclosure={storedDisclosure}
279
+ onClose={() => {}}
280
+ additionalSecrets={additionalSecrets}
281
+ onCopy={onCopy}
282
+ />,
283
+ );
284
+ const dsn = await screen.findByRole('group', { name: 'DSN' });
285
+ const observation = observeAnnouncements();
286
+ const button = within(dsn).getByRole('button', { name: 'Copy secret' });
287
+ await user.click(button);
288
+ await user.click(button);
289
+ expect(observation.messages).toEqual(['DSN: Copied', 'DSN: Copied']);
290
+ writeText.mockRejectedValueOnce(new Error('Synthetic denial'));
291
+ await user.click(button);
292
+ expect(observation.messages).toHaveLength(3);
293
+ expect(observation.messages[2]).not.toContain('synthetic_');
294
+ for (let attempt = 0; attempt < 2; attempt++) {
295
+ writeText.mockClear();
296
+ onCopy.mockClear();
297
+ observation.messages.length = 0;
298
+ writeText.mockImplementationOnce(
299
+ () =>
300
+ new Promise<void>((resolve) => {
301
+ settle = resolve;
302
+ }),
303
+ );
304
+ await user.click(button);
305
+ fireEvent.click(button);
306
+ expect(button).toHaveAttribute('aria-busy', 'true');
307
+ expect(writeText).toHaveBeenCalledExactlyOnceWith('synthetic_dsn_value');
308
+ expect(onCopy).not.toHaveBeenCalled();
309
+ expect(observation.messages).toEqual([]);
310
+ await act(async () => settle());
311
+ expect(writeText).toHaveBeenCalledOnce();
312
+ expect(onCopy).toHaveBeenCalledExactlyOnceWith('dsn');
313
+ expect(observation.messages).toEqual(['DSN: Copied']);
314
+ }
315
+ observation.stop();
316
+ });
317
+
318
+ it('announces two secrets settling in the same tick through separate regions', async () => {
319
+ const user = userEvent.setup();
320
+ const settlements: (() => void)[] = [];
321
+ const writeText = vi.fn(() => new Promise<void>((resolve) => settlements.push(resolve)));
322
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
323
+ const onCopy = vi.fn();
324
+ render(
325
+ <CredentialDisclosureDialog
326
+ disclosure={storedDisclosure}
327
+ onClose={() => {}}
328
+ additionalSecrets={additionalSecrets}
329
+ onCopy={onCopy}
330
+ />,
331
+ );
332
+ await screen.findByRole('dialog');
333
+ const observation = observeAnnouncements();
334
+ await user.click(within(screen.getByRole('group', { name: 'Secret' })).getByRole('button', { name: 'Copy secret' }));
335
+ await user.click(within(screen.getByRole('group', { name: 'DSN' })).getByRole('button', { name: 'Copy secret' }));
336
+ await act(async () => settlements.forEach((resolve) => resolve()));
337
+ expect(observation.messages.sort()).toEqual(['DSN: Copied', 'Secret: Copied']);
338
+ expect(onCopy.mock.calls).toEqual([['secret'], ['dsn']]);
339
+ observation.stop();
340
+ });
341
+
342
+ it('keeps synthetic values out of persistence, navigation, announcements and console across reveal/copy/close/remount', async () => {
343
+ const user = userEvent.setup();
344
+ const logs = ['log', 'info', 'warn', 'error', 'debug'].map((method) => vi.spyOn(console, method as 'log'));
345
+ const localWrite = vi.spyOn(Storage.prototype, 'setItem');
346
+ const cookieWrite = vi.spyOn(document, 'cookie', 'set');
347
+ const push = vi.spyOn(history, 'pushState');
348
+ const replace = vi.spyOn(history, 'replaceState');
349
+ const indexedOpen = vi.fn();
350
+ vi.stubGlobal('indexedDB', { open: indexedOpen });
351
+ const initialLocation = location.href;
352
+ const writeText = vi.fn().mockResolvedValue(undefined);
353
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
354
+ try {
355
+ for (const presentation of ['dialog', 'inline'] as const) {
356
+ const { unmount } = render(
357
+ <CredentialDisclosureDialog
358
+ disclosure={storedDisclosure}
359
+ additionalSecrets={additionalSecrets}
360
+ presentation={presentation}
361
+ onClose={() => {}}
362
+ revealContent={<p>synthetic_slot_value</p>}
363
+ />,
364
+ );
365
+ await screen.findByRole(presentation === 'dialog' ? 'dialog' : 'region', { name: 'Credential secret' });
366
+ for (const button of screen.getAllByRole('button', { name: 'Show secret' })) await user.click(button);
367
+ for (const button of screen.getAllByRole('button', { name: 'Copy secret' })) await user.click(button);
368
+ for (const textbox of screen.getAllByRole('textbox')) expect(textbox.closest('[data-kit-redact]')).not.toBeNull();
369
+ for (const region of document.querySelectorAll('[data-live-announcer]'))
370
+ expect(region.textContent).not.toContain('synthetic_');
371
+ await user.click(screen.getByRole('button', { name: 'Close' }));
372
+ unmount();
373
+ }
374
+ expect(localWrite).not.toHaveBeenCalled();
375
+ expect(cookieWrite).not.toHaveBeenCalled();
376
+ expect(indexedOpen).not.toHaveBeenCalled();
377
+ expect(push).not.toHaveBeenCalled();
378
+ expect(replace).not.toHaveBeenCalled();
379
+ expect(location.href).toBe(initialLocation);
380
+ for (const value of [
381
+ JSON.stringify(Object.entries(localStorage)),
382
+ JSON.stringify(Object.entries(sessionStorage)),
383
+ document.cookie,
384
+ JSON.stringify(history.state),
385
+ ])
386
+ expect(String(value)).not.toContain('synthetic_');
387
+ for (const log of logs) expect(JSON.stringify(log.mock.calls)).not.toContain('synthetic_');
388
+ } finally {
389
+ logs.forEach((log) => log.mockRestore());
390
+ localWrite.mockRestore();
391
+ cookieWrite.mockRestore();
392
+ push.mockRestore();
393
+ replace.mockRestore();
394
+ vi.unstubAllGlobals();
395
+ }
396
+ });
397
+
398
+ it('returns modal focus when the caller clears disclosure directly', async () => {
399
+ const outside = document.createElement('button');
400
+ document.body.append(outside);
401
+ outside.focus();
402
+ const props = { disclosure: storedDisclosure, onClose: vi.fn(), resolveReturnFocus: () => outside };
403
+ const { rerender } = render(<CredentialDisclosureDialog {...props} />);
404
+ await waitFor(() => expect(screen.getByRole('dialog')).toHaveFocus());
405
+ rerender(<CredentialDisclosureDialog {...props} disclosure={null} />);
406
+ await waitFor(() => expect(outside).toHaveFocus());
407
+ outside.remove();
408
+ });
409
+
410
+ it('keeps single-secret defaults when no additional id is valid, including the fresh CodeBlock and announcement', async () => {
411
+ const user = userEvent.setup();
412
+ const writeText = vi.fn().mockResolvedValue(undefined);
413
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
414
+ const onCopy = vi.fn();
415
+ render(
416
+ <CredentialDisclosureDialog
417
+ disclosure={{ ...storedDisclosure, variant: 'fresh' }}
418
+ onClose={() => {}}
419
+ additionalSecrets={[
420
+ { id: '', label: 'Empty', value: 'bad' },
421
+ { id: 'secret', label: 'Reserved', value: 'bad' },
422
+ ]}
423
+ copiedAnnouncementFor={() => 'Unused override'}
424
+ onCopy={onCopy}
425
+ />,
426
+ );
427
+ await screen.findByRole('dialog');
428
+ const code = screen.getByRole('region', { name: 'Secret' });
429
+ expect(code).toHaveTextContent(storedDisclosure.secret);
430
+ expect(code.closest('[data-kit-redact]')).not.toBeNull();
431
+ expect(screen.queryByRole('group')).not.toBeInTheDocument();
432
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
433
+ await user.click(screen.getByRole('button', { name: 'Copy secret' }));
434
+ expect(document.querySelector('[data-live-announcer="polite"]')?.textContent).toBe('Secret copied to clipboard');
435
+ expect(onCopy).toHaveBeenCalledExactlyOnceWith('secret');
436
+ });
437
+
438
+ it('does not claim focus when an unrelated focused control outside the inline section is removed', () => {
439
+ const outside = document.createElement('button');
440
+ document.body.append(outside);
441
+ const props = { disclosure: storedDisclosure, presentation: 'inline' as const, onClose: vi.fn() };
442
+ const { rerender } = render(<CredentialDisclosureDialog {...props} />);
443
+ outside.focus();
444
+ outside.remove();
445
+ rerender(<CredentialDisclosureDialog {...props} revealContent={<p>Updated content</p>} />);
446
+ expect(screen.getByRole('region', { name: 'Credential secret' }).contains(document.activeElement)).toBe(false);
447
+ });
448
+
449
+ it.each(['dialog', 'inline'] as const)('multi-secret %s is axe-clean in all three themes', async (presentation) => {
450
+ const { unmount } = render(
451
+ <CredentialDisclosureDialog
452
+ disclosure={{ ...storedDisclosure, variant: 'fresh' }}
453
+ presentation={presentation}
454
+ maskFresh
455
+ additionalSecrets={additionalSecrets}
456
+ onClose={() => {}}
457
+ />,
458
+ );
459
+ const surface = await screen.findByRole(presentation === 'dialog' ? 'dialog' : 'region', {
460
+ name: 'Credential secret',
461
+ });
462
+ for (const theme of ['dark', 'light', 'high_contrast']) {
463
+ document.documentElement.className = theme;
464
+ await expectNoAxeViolations(surface);
465
+ }
466
+ unmount();
467
+ });