@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
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  Button,
3
- CopyButton,
4
3
  Dialog,
5
4
  DialogContent,
6
5
  DialogHeader,
@@ -9,13 +8,15 @@ import {
9
8
  DialogBody,
10
9
  DialogFooter,
11
10
  DialogClose,
12
- LiveAnnouncer,
13
- SecretField,
14
- CodeBlock,
15
11
  Alert,
16
12
  useI18n,
17
13
  } from '@gntik-ai/ui';
18
- import { useRef, useState, type ReactNode } from 'react';
14
+ import { useEffectEvent, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
15
+ import { SecretCopy } from './SecretCopy';
16
+ import { usableFocusTarget } from './focus';
17
+ import type { CredentialSecret } from './types';
18
+
19
+ export type { CredentialSecret } from './types';
19
20
 
20
21
  export interface CredentialDisclosure {
21
22
  variant: 'stored' | 'fresh';
@@ -26,9 +27,21 @@ export interface CredentialDisclosure {
26
27
  }
27
28
 
28
29
  export interface CredentialDisclosureDialogProps {
30
+ /** Additional masked values; ids must be unique, non-empty and not "secret". */
31
+ additionalSecrets?: readonly CredentialSecret[];
32
+ /** Mask a fresh primary secret; read at the start of each session. */
33
+ maskFresh?: boolean;
34
+ /** Redacted content below the secrets; clear secret-bearing content on close. */
35
+ revealContent?: ReactNode;
36
+ /** Presentation fixed for each non-null opening. Defaults to a modal dialog. */
37
+ presentation?: 'dialog' | 'inline';
38
+ /** Called after copying with the secret id (primary: "secret"), never its value. */
39
+ onCopy?: (id: string) => void;
40
+ /** Label-only success announcement override, used only in multi-secret mode. */
41
+ copiedAnnouncementFor?: (label: string) => string;
29
42
  /** Set only after a successful response. Leave null while pending or on failure. */
30
43
  disclosure: CredentialDisclosure | null;
31
- /** Clear disclosure to null on dismissal, dropping the secret in the consumer. */
44
+ /** Clear disclosure, additionalSecrets and secret-bearing revealContent on dismissal. */
32
45
  onClose: () => void;
33
46
  /** Resolve the current row action at close time, including after list reloads. */
34
47
  resolveReturnFocus?: () => HTMLElement | null;
@@ -51,9 +64,10 @@ export interface CredentialDisclosureDialogProps {
51
64
  }
52
65
 
53
66
  /**
54
- * Discloses an existing credential only after a successful response. Consumers keep disclosure
55
- * null while pending or on failure, and clear it on close. No pending/error state or internal
56
- * secret retention. A fresh secret requires a new successful response to be shown again.
67
+ * Render only successful responses. Keep disclosure null while pending or on failure.
68
+ * On close, clear disclosure, additionalSecrets and any secret-bearing revealContent.
69
+ * No pending/error state or internal secret retention. Retained dismissed sessions stay closed;
70
+ * a changed credential/value or a null-to-value opening starts a new session.
57
71
  */
58
72
  export function CredentialDisclosureDialog(props: CredentialDisclosureDialogProps) {
59
73
  if (!props.disclosure) return null;
@@ -61,108 +75,182 @@ export function CredentialDisclosureDialog(props: CredentialDisclosureDialogProp
61
75
  }
62
76
 
63
77
  type OpeningProps = CredentialDisclosureDialogProps & { disclosure: CredentialDisclosure };
78
+ type FocusScope = {
79
+ onRootRef: (node: HTMLElement | null) => void;
80
+ onTargetRef: (node: HTMLElement | null) => void;
81
+ onPopupRef: (node: HTMLDivElement | null) => void;
82
+ containsFocus: () => boolean;
83
+ initialFocus: () => HTMLElement | false | null;
84
+ };
64
85
 
65
86
  function DisclosureOpening(props: OpeningProps) {
66
- // Keep the original opener across credential/value changes; the old popup is not an opener.
67
87
  const [opener] = useState(() => (typeof document === 'undefined' ? null : document.activeElement));
88
+ const [presentation] = useState(props.presentation ?? 'dialog');
89
+ const root = useRef<HTMLElement>(null);
90
+ const target = useRef<HTMLElement>(null);
91
+ const active = useRef<Element | null>(opener);
92
+ const started = useRef(false);
93
+ const activeWasInside = useRef(false);
94
+ const returnFocus = () => {
95
+ const current = props.resolveReturnFocus?.();
96
+ if (usableFocusTarget(current)) return current;
97
+ const fallback = props.fallbackFocus?.();
98
+ if (usableFocusTarget(fallback)) return fallback;
99
+ return usableFocusTarget(opener) ? opener : false;
100
+ };
101
+ const restore = useEffectEvent(() => {
102
+ if (root.current?.contains(document.activeElement)) {
103
+ const element = returnFocus();
104
+ if (element) element.focus();
105
+ }
106
+ });
107
+ useLayoutEffect(() => {
108
+ const track = () => {
109
+ if (document.activeElement !== document.body) {
110
+ active.current = document.activeElement;
111
+ activeWasInside.current = root.current?.contains(document.activeElement) ?? false;
112
+ }
113
+ };
114
+ document.addEventListener('focusin', track);
115
+ return () => {
116
+ document.removeEventListener('focusin', track);
117
+ restore();
118
+ };
119
+ }, [presentation]);
120
+ useLayoutEffect(() => {
121
+ // A surviving focused control wins, including one outside the disclosure.
122
+ if (activeWasInside.current && active.current && !active.current.isConnected) target.current?.focus();
123
+ });
124
+ const seen = new Set(['secret', '']);
125
+ const additionalSecrets = (props.additionalSecrets ?? []).filter(({ id }) => {
126
+ if (seen.has(id)) return false;
127
+ seen.add(id);
128
+ return true;
129
+ });
68
130
  const { variant, credentialId, secret } = props.disclosure;
69
- // A new credential/value is a new session: reveal and announcement state cannot carry over.
70
- // React keys are never rendered as DOM attributes.
71
- return <DisclosureSession key={JSON.stringify([variant, credentialId, secret])} {...props} opener={opener} />;
131
+ return (
132
+ <DisclosureSession
133
+ key={JSON.stringify([variant, credentialId, secret, ...additionalSecrets.map(({ id, value }) => [id, value])])}
134
+ {...props}
135
+ presentation={presentation}
136
+ additionalSecrets={additionalSecrets}
137
+ onRootRef={(node) => {
138
+ root.current = node;
139
+ }}
140
+ onTargetRef={(node) => {
141
+ target.current = node;
142
+ }}
143
+ onPopupRef={(node) => {
144
+ root.current = node;
145
+ target.current = node;
146
+ }}
147
+ containsFocus={() => root.current?.contains(document.activeElement) ?? false}
148
+ initialFocus={() => {
149
+ const shouldFocus = !started.current || (activeWasInside.current && !active.current?.isConnected);
150
+ started.current = true;
151
+ return shouldFocus ? root.current : false;
152
+ }}
153
+ returnFocus={returnFocus}
154
+ />
155
+ );
72
156
  }
73
157
 
74
- function DisclosureSession({
75
- disclosure,
76
- onClose,
77
- resolveReturnFocus,
78
- fallbackFocus,
79
- opener,
80
- ...labels
81
- }: OpeningProps & { opener: Element | null }) {
158
+ function DisclosureSession(props: OpeningProps & FocusScope & { returnFocus: () => HTMLElement | false }) {
82
159
  const { t } = useI18n();
160
+ const {
161
+ disclosure,
162
+ onRootRef,
163
+ onTargetRef,
164
+ onPopupRef,
165
+ initialFocus,
166
+ containsFocus,
167
+ presentation,
168
+ additionalSecrets = [],
169
+ } = props;
83
170
  const [open, setOpen] = useState(true);
84
- const popup = useRef<HTMLDivElement>(null);
171
+ const [maskFresh] = useState(props.maskFresh ?? false);
85
172
  const dismissed = useRef(false);
86
- const returnFocus = () => {
87
- const target = resolveReturnFocus?.();
88
- if (usableFocusTarget(target)) return target;
89
- const fallback = fallbackFocus?.();
90
- if (usableFocusTarget(fallback)) return fallback;
91
- return usableFocusTarget(opener) ? opener : false;
173
+ const headingId = useId();
174
+ const close = () => {
175
+ if (dismissed.current) return;
176
+ dismissed.current = true;
177
+ if (presentation === 'inline' && containsFocus()) {
178
+ const element = props.returnFocus();
179
+ if (element) element.focus();
180
+ }
181
+ setOpen(false);
182
+ props.onClose();
92
183
  };
93
- const closeLabel = labels.closeLabel ?? t('credentialDisclosure.close');
94
- const secretLabel = labels.secretLabel ?? t('credentialDisclosure.secret');
184
+ const closeLabel = props.closeLabel ?? t('credentialDisclosure.close');
185
+ const title = props.title ?? t('credentialDisclosure.title');
186
+ const description = props.description ?? t('credentialDisclosure.description');
187
+ const secretLabel = props.secretLabel ?? t('credentialDisclosure.secret');
188
+ const warning =
189
+ disclosure.variant === 'fresh' ? (
190
+ <Alert
191
+ role={presentation === 'inline' ? 'note' : 'alert'}
192
+ tone="warning"
193
+ title={props.warningTitle ?? t('credentialDisclosure.warningTitle')}
194
+ description={props.warningText ?? t('credentialDisclosure.warningText')}
195
+ />
196
+ ) : null;
197
+ const body = (
198
+ <>
199
+ <dl className="grid grid-cols-2 gap-2 text-sm">
200
+ <dt className="text-muted-foreground">{props.credentialIdLabel ?? t('credentialDisclosure.credentialId')}</dt>
201
+ <dd className="break-all font-mono">{disclosure.credentialId}</dd>
202
+ <dt className="text-muted-foreground">{props.expiryLabel ?? t('credentialDisclosure.expiry')}</dt>
203
+ <dd>{disclosure.expiresAt}</dd>
204
+ </dl>
205
+ <SecretCopy
206
+ secret={{ id: 'secret', label: secretLabel, value: disclosure.secret }}
207
+ options={props}
208
+ grouped={additionalSecrets.length > 0}
209
+ masked={disclosure.variant === 'stored' || maskFresh}
210
+ warning={warning}
211
+ />
212
+ {additionalSecrets.map((secret) => (
213
+ <SecretCopy key={secret.id} secret={secret} options={props} grouped />
214
+ ))}
215
+ {props.revealContent != null && <div data-kit-redact>{props.revealContent}</div>}
216
+ </>
217
+ );
218
+ if (presentation === 'inline') {
219
+ if (!open) return null;
220
+ return (
221
+ <section ref={onRootRef} aria-labelledby={headingId} className="grid gap-4">
222
+ <h2 id={headingId} tabIndex={-1} ref={onTargetRef}>
223
+ {title}
224
+ </h2>
225
+ <p>{description}</p>
226
+ {body}
227
+ <Button variant="secondary" onClick={close}>
228
+ {closeLabel}
229
+ </Button>
230
+ </section>
231
+ );
232
+ }
95
233
  return (
96
234
  <Dialog
97
235
  open={open}
98
236
  onOpenChange={(next) => {
99
- if (!next && !dismissed.current) {
100
- dismissed.current = true;
101
- setOpen(false);
102
- onClose();
103
- }
237
+ if (!next) close();
104
238
  }}
105
239
  >
106
240
  <DialogContent
107
- ref={popup}
108
- initialFocus={popup}
109
- finalFocus={returnFocus}
241
+ ref={onPopupRef}
242
+ initialFocus={initialFocus}
243
+ finalFocus={() => (dismissed.current ? props.returnFocus() : false)}
110
244
  showClose={false}
111
245
  closeLabel={closeLabel}
112
246
  >
113
247
  {open && (
114
248
  <>
115
249
  <DialogHeader>
116
- <DialogTitle>{labels.title ?? t('credentialDisclosure.title')}</DialogTitle>
117
- <DialogDescription>{labels.description ?? t('credentialDisclosure.description')}</DialogDescription>
250
+ <DialogTitle>{title}</DialogTitle>
251
+ <DialogDescription>{description}</DialogDescription>
118
252
  </DialogHeader>
119
- <DialogBody className="grid gap-4">
120
- <dl className="grid grid-cols-2 gap-2 text-sm">
121
- <dt className="text-muted-foreground">
122
- {labels.credentialIdLabel ?? t('credentialDisclosure.credentialId')}
123
- </dt>
124
- <dd className="break-all font-mono">{disclosure.credentialId}</dd>
125
- <dt className="text-muted-foreground">{labels.expiryLabel ?? t('credentialDisclosure.expiry')}</dt>
126
- <dd>{disclosure.expiresAt}</dd>
127
- </dl>
128
- <LiveAnnouncer>
129
- {disclosure.variant === 'stored' ? (
130
- <SecretField
131
- value={disclosure.secret}
132
- copyable={false}
133
- aria-label={secretLabel}
134
- showLabel={labels.showLabel ?? t('credentialDisclosure.show')}
135
- hideLabel={labels.hideLabel ?? t('credentialDisclosure.hide')}
136
- />
137
- ) : (
138
- <>
139
- <Alert
140
- role="alert"
141
- tone="warning"
142
- title={labels.warningTitle ?? t('credentialDisclosure.warningTitle')}
143
- description={labels.warningText ?? t('credentialDisclosure.warningText')}
144
- />
145
- <CodeBlock
146
- code={disclosure.secret}
147
- copyable={false}
148
- label={secretLabel}
149
- wrapToggle={false}
150
- defaultWrap
151
- />
152
- </>
153
- )}
154
- <CopyButton
155
- value={() => disclosure.secret}
156
- display="label"
157
- label={labels.copyLabel ?? t('credentialDisclosure.copy')}
158
- copiedLabel={labels.copiedLabel ?? t('credentialDisclosure.copied')}
159
- announcement={labels.successAnnouncement ?? t('credentialDisclosure.success')}
160
- failureLabel={labels.failureAnnouncement ?? t('credentialDisclosure.failure')}
161
- failureAnnouncement={labels.failureAnnouncement ?? t('credentialDisclosure.failure')}
162
- failurePoliteness="polite"
163
- />
164
- </LiveAnnouncer>
165
- </DialogBody>
253
+ <DialogBody className="grid gap-4">{body}</DialogBody>
166
254
  <DialogFooter>
167
255
  <DialogClose render={<Button variant="secondary" />}>{closeLabel}</DialogClose>
168
256
  </DialogFooter>
@@ -172,22 +260,3 @@ function DisclosureSession({
172
260
  </Dialog>
173
261
  );
174
262
  }
175
-
176
- /** Programmatic focus permits negative tabIndex, but never hidden, inert or disabled targets. */
177
- function usableFocusTarget(element: Element | null | undefined): element is HTMLElement {
178
- if (!(element instanceof HTMLElement) || !element.isConnected || element === document.body) return false;
179
- if (element.matches(':disabled, [disabled], [aria-disabled="true"], input[type="hidden"]')) return false;
180
- for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) {
181
- if (ancestor.hidden || ancestor.hasAttribute('inert') || ancestor.getAttribute('aria-disabled') === 'true')
182
- return false;
183
- const style = getComputedStyle(ancestor);
184
- if (
185
- style.display === 'none' ||
186
- style.visibility === 'hidden' ||
187
- style.visibility === 'collapse' ||
188
- style.contentVisibility === 'hidden'
189
- )
190
- return false;
191
- }
192
- return element.tabIndex >= 0 || element.hasAttribute('tabindex') || element.isContentEditable;
193
- }
@@ -0,0 +1,56 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import IssueOnClick from './examples/IssueOnClick';
4
+ import { freshDisclosure } from './fixtures';
5
+ import type { CredentialDisclosure } from './CredentialDisclosureDialog';
6
+
7
+ it.each(['cancel', 'supersede'] as const)('ignores a deferred issuance after %s', async (action) => {
8
+ const user = userEvent.setup();
9
+ const results: ((value: CredentialDisclosure) => void)[] = [];
10
+ const signals: AbortSignal[] = [];
11
+ const request = vi.fn((signal: AbortSignal) => {
12
+ signals.push(signal);
13
+ return new Promise<CredentialDisclosure>((resolve) => results.push(resolve));
14
+ });
15
+ render(<IssueOnClick request={request} />);
16
+ await user.click(screen.getByRole('button', { name: 'Issue credential' }));
17
+ await user.click(screen.getByRole('button', { name: action === 'cancel' ? 'Cancel request' : 'Supersede request' }));
18
+ expect(signals[0]!.aborted).toBe(true);
19
+ await act(async () => results[0]!(freshDisclosure));
20
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
21
+ expect(document.body.textContent).not.toContain(freshDisclosure.secret);
22
+ if (action === 'supersede') {
23
+ await act(async () => results[1]!({ ...freshDisclosure, secret: 'synthetic_current_response' }));
24
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
25
+ await user.click(screen.getByRole('button', { name: 'Show secret' }));
26
+ expect(screen.getByRole('textbox')).toHaveValue('synthetic_current_response');
27
+ }
28
+ });
29
+
30
+ it('cancels the pending request on unmount before its deferred response settles', async () => {
31
+ const user = userEvent.setup();
32
+ const onAbort = vi.fn();
33
+ let settle!: (value: CredentialDisclosure) => void;
34
+ const request = vi.fn((signal: AbortSignal) => {
35
+ signal.addEventListener('abort', onAbort);
36
+ return new Promise<CredentialDisclosure>((resolve) => {
37
+ settle = resolve;
38
+ });
39
+ });
40
+ const { unmount } = render(<IssueOnClick request={request} />);
41
+ await user.click(screen.getByRole('button', { name: 'Issue credential' }));
42
+ expect(onAbort).not.toHaveBeenCalled();
43
+ unmount();
44
+ expect(onAbort).toHaveBeenCalledOnce();
45
+ expect(request.mock.calls[0]![0].aborted).toBe(true);
46
+ await act(async () => settle(freshDisclosure));
47
+ });
48
+
49
+ it('keeps disclosure null on failure and shows only a caller-owned error', async () => {
50
+ const user = userEvent.setup();
51
+ render(<IssueOnClick request={() => Promise.reject(new Error('409 DB_SHARED_MODE synthetic_not_for_display'))} />);
52
+ await user.click(screen.getByRole('button', { name: 'Issue credential' }));
53
+ expect(await screen.findByRole('alert')).toHaveTextContent('No credential was disclosed.');
54
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
55
+ expect(document.body.textContent).not.toContain('DB_SHARED_MODE');
56
+ });
@@ -0,0 +1,83 @@
1
+ import { CodeBlock, CopyButton, LiveAnnouncer, SecretField, useI18n } from '@gntik-ai/ui';
2
+ import { useState, type ReactNode } from 'react';
3
+ import type { CredentialSecret } from './types';
4
+
5
+ interface SecretCopyOptions {
6
+ showLabel?: string;
7
+ hideLabel?: string;
8
+ copyLabel?: string;
9
+ copiedLabel?: string;
10
+ successAnnouncement?: string;
11
+ failureAnnouncement?: string;
12
+ onCopy?: (id: string) => void;
13
+ copiedAnnouncementFor?: (label: string) => string;
14
+ }
15
+
16
+ /** Each secret owns feedback so simultaneous settlements cannot overwrite each other. */
17
+ export function SecretCopy({
18
+ secret,
19
+ options,
20
+ grouped = false,
21
+ masked = true,
22
+ warning,
23
+ }: {
24
+ secret: CredentialSecret;
25
+ options: SecretCopyOptions;
26
+ grouped?: boolean;
27
+ masked?: boolean;
28
+ warning?: ReactNode;
29
+ }) {
30
+ const { t } = useI18n();
31
+ const [pending, setPending] = useState(false);
32
+ const copied = options.copiedLabel ?? t('credentialDisclosure.copied');
33
+ const content = (
34
+ <LiveAnnouncer>
35
+ {warning}
36
+ <div data-kit-redact>
37
+ {masked ? (
38
+ <SecretField
39
+ value={secret.value}
40
+ copyable={false}
41
+ aria-label={secret.label}
42
+ showLabel={options.showLabel ?? t('credentialDisclosure.show')}
43
+ hideLabel={options.hideLabel ?? t('credentialDisclosure.hide')}
44
+ />
45
+ ) : (
46
+ <CodeBlock code={secret.value} copyable={false} label={secret.label} wrapToggle={false} defaultWrap />
47
+ )}
48
+ </div>
49
+ <CopyButton
50
+ value={() => {
51
+ if (grouped) setPending(true);
52
+ return secret.value;
53
+ }}
54
+ loading={grouped && pending}
55
+ display="label"
56
+ label={options.copyLabel ?? t('credentialDisclosure.copy')}
57
+ copiedLabel={copied}
58
+ announcement={
59
+ grouped
60
+ ? (options.copiedAnnouncementFor?.(secret.label) ?? `${secret.label}: ${copied}`)
61
+ : (options.successAnnouncement ?? t('credentialDisclosure.success'))
62
+ }
63
+ failureLabel={options.failureAnnouncement ?? t('credentialDisclosure.failure')}
64
+ failureAnnouncement={options.failureAnnouncement ?? t('credentialDisclosure.failure')}
65
+ failurePoliteness="polite"
66
+ onCopy={() => {
67
+ if (grouped) setPending(false);
68
+ options.onCopy?.(secret.id);
69
+ }}
70
+ onCopyError={() => {
71
+ if (grouped) setPending(false);
72
+ }}
73
+ />
74
+ </LiveAnnouncer>
75
+ );
76
+ return grouped ? (
77
+ <div role="group" aria-label={secret.label}>
78
+ {content}
79
+ </div>
80
+ ) : (
81
+ content
82
+ );
83
+ }
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
5
5
  family: 'forms',
6
6
  status: 'beta',
7
7
  description:
8
- 'Controlled disclosure for an existing credential. Set disclosure ({variant: stored | fresh, credentialId, expiresAt: caller-formatted ReactNode, secret}) only after a successful response; null renders nothing while pending or on failure. Clear disclosure to null in onClose to drop the secret; a fresh secret requires a new successful response before showing it again. No pending/error state or internal secret retention. Stored values start masked; each null-to-value opening or changed credentialId/secret resets masking and copy feedback. Fresh values use a non-copyable CodeBlock with a kit CopyButton and a replacement warning. Optional English defaults through en/es i18n for title, description, credentialIdLabel, expiryLabel, secretLabel, showLabel, hideLabel, copyLabel, copiedLabel, successAnnouncement, failureAnnouncement, warningTitle, warningText and closeLabel. Copy results are announced politely, including manual-selection fallback. Popup initial focus and focus trapping use Base UI; at close, resolveReturnFocus() is validated for connection, visibility, enabled state and focusability, then fallbackFocus(), then the original opener. Synthetic fixtures only; the default Stored preview is masked and the Fresh preview starts closed.',
8
+ 'Controlled disclosure for an existing credential. Set disclosure ({variant: stored | fresh, credentialId, expiresAt: caller-formatted ReactNode, secret}) only after a successful response; null renders nothing while pending or on failure. Clear disclosure, additionalSecrets and secret-bearing revealContent in onClose to drop every secret; a fresh secret requires a new successful response before showing it again. No pending/error state or internal secret retention. Stored values start masked; each null-to-value opening or changed credentialId/secret resets masking and copy feedback. Fresh values use a non-copyable CodeBlock with a kit CopyButton and a replacement warning, or a masked SecretField when maskFresh is set at session start. Optional additionalSecrets ({id, label, value}) are always masked and use independent toggles and serialized copy actions; ids must be non-empty, unique and not secret, with the first duplicate winning. Multi-secret groups announce label plus copiedLabel, or copiedAnnouncementFor(label); onCopy(id) never receives a value. Every secret and the optional revealContent slot have data-kit-redact. The session key includes additional ids and values. presentation is dialog by default or inline, fixed until the next opening: inline is a named section, never traps focus or handles Escape, and its warning is a non-live note. Surviving focus stays; removing the focused slot or session control focuses the popup or inline heading. Focused inline unmounts return focus through the same resolvers. Dismissed retained props stay hidden; clear and re-set or change a session value to reopen. Optional English defaults through en/es i18n for title, description, credentialIdLabel, expiryLabel, secretLabel, showLabel, hideLabel, copyLabel, copiedLabel, successAnnouncement, failureAnnouncement, warningTitle, warningText and closeLabel. Copy results are announced politely, including manual-selection fallback. Popup initial focus and focus trapping use Base UI; at close, resolveReturnFocus() is validated for connection, visibility, enabled state and focusability, then fallbackFocus(), then the original opener. Synthetic fixtures only; the default Stored preview is masked and the Fresh preview starts closed.',
9
9
  uses: ['Dialog', 'SecretField', 'CodeBlock', 'CopyButton', 'Alert', 'LiveAnnouncer', 'Button'],
10
10
  };
@@ -0,0 +1,19 @@
1
+ import { useState } from 'react';
2
+ import { CredentialDisclosureDialog } from '../CredentialDisclosureDialog';
3
+ import { freshDisclosure } from '../fixtures';
4
+
5
+ export default function Inline() {
6
+ const [open, setOpen] = useState(true);
7
+ if (!open) return null;
8
+ return (
9
+ <CredentialDisclosureDialog
10
+ disclosure={freshDisclosure}
11
+ presentation="inline"
12
+ maskFresh
13
+ onClose={() => setOpen(false)}
14
+ title="Connection credentials"
15
+ secretLabel="DSN"
16
+ additionalSecrets={[{ id: 'password', label: 'Password', value: 'synthetic_password_for_demo_only' }]}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,87 @@
1
+ import { Alert, Button } from '@gntik-ai/ui';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { CredentialDisclosureDialog, type CredentialDisclosure } from '../CredentialDisclosureDialog';
4
+ import { freshDisclosure } from '../fixtures';
5
+
6
+ const syntheticRequest = async () => ({ ...freshDisclosure });
7
+
8
+ /** Page recipe: the caller owns issuance, cancellation and errors. Fixtures are synthetic. */
9
+ export default function IssueOnClick({
10
+ request = syntheticRequest,
11
+ }: {
12
+ request?: (signal: AbortSignal) => Promise<CredentialDisclosure>;
13
+ }) {
14
+ const [disclosure, setDisclosure] = useState<CredentialDisclosure | null>(null);
15
+ const [pending, setPending] = useState(false);
16
+ const [failed, setFailed] = useState(false);
17
+ const current = useRef<AbortController | null>(null);
18
+ const trigger = useRef<HTMLButtonElement>(null);
19
+ useEffect(
20
+ () => () => {
21
+ current.current?.abort();
22
+ },
23
+ [],
24
+ );
25
+ const issue = async () => {
26
+ current.current?.abort();
27
+ const controller = new AbortController();
28
+ current.current = controller;
29
+ setDisclosure(null);
30
+ setFailed(false);
31
+ setPending(true);
32
+ try {
33
+ const response = await request(controller.signal);
34
+ if (!controller.signal.aborted) setDisclosure(response);
35
+ } catch {
36
+ if (!controller.signal.aborted) setFailed(true);
37
+ } finally {
38
+ if (!controller.signal.aborted) {
39
+ current.current = null;
40
+ setPending(false);
41
+ }
42
+ }
43
+ };
44
+ return (
45
+ <>
46
+ <h1>Issue a credential</h1>
47
+ <div className="flex flex-wrap gap-2">
48
+ <Button
49
+ ref={trigger}
50
+ loading={pending}
51
+ onClick={() => {
52
+ void issue();
53
+ }}
54
+ >
55
+ Issue credential
56
+ </Button>
57
+ {pending && (
58
+ <>
59
+ <Button
60
+ onClick={() => {
61
+ current.current?.abort();
62
+ current.current = null;
63
+ setPending(false);
64
+ }}
65
+ >
66
+ Cancel request
67
+ </Button>
68
+ <Button
69
+ onClick={() => {
70
+ void issue();
71
+ }}
72
+ >
73
+ Supersede request
74
+ </Button>
75
+ </>
76
+ )}
77
+ </div>
78
+ {failed && <Alert tone="destructive" title="Request failed" description="No credential was disclosed." />}
79
+ <CredentialDisclosureDialog
80
+ disclosure={disclosure}
81
+ maskFresh
82
+ onClose={() => setDisclosure(null)}
83
+ resolveReturnFocus={() => trigger.current}
84
+ />
85
+ </>
86
+ );
87
+ }
@@ -0,0 +1,29 @@
1
+ import { CodeBlock } from '@gntik-ai/ui';
2
+ import { useState } from 'react';
3
+ import { CredentialDisclosureDialog } from '../CredentialDisclosureDialog';
4
+ import { freshDisclosure } from '../fixtures';
5
+
6
+ /** All values and snippets are synthetic; closing unmounts every secret-bearing prop. */
7
+ export default function MultiSecret() {
8
+ const [open, setOpen] = useState(true);
9
+ if (!open) return null;
10
+ return (
11
+ <CredentialDisclosureDialog
12
+ disclosure={freshDisclosure}
13
+ maskFresh
14
+ onClose={() => setOpen(false)}
15
+ secretLabel="Access key"
16
+ additionalSecrets={[
17
+ { id: 'dsn', label: 'DSN', value: 'synthetic_dsn_for_demo_only' },
18
+ { id: 'password', label: 'Password', value: 'synthetic_password_for_demo_only' },
19
+ ]}
20
+ revealContent={
21
+ <CodeBlock
22
+ copyable={false}
23
+ label="Integration examples"
24
+ code={`fetch('/example', { headers: { Authorization: '${freshDisclosure.secret}' } })\ncurl -H 'Authorization: ${freshDisclosure.secret}' /example`}
25
+ />
26
+ }
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,18 @@
1
+ /** Programmatic focus permits negative tabIndex, but never hidden, inert or disabled targets. */
2
+ export function usableFocusTarget(element: Element | null | undefined): element is HTMLElement {
3
+ if (!(element instanceof HTMLElement) || !element.isConnected || element === document.body) return false;
4
+ if (element.matches(':disabled, [disabled], [aria-disabled="true"], input[type="hidden"]')) return false;
5
+ for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) {
6
+ if (ancestor.hidden || ancestor.hasAttribute('inert') || ancestor.getAttribute('aria-disabled') === 'true')
7
+ return false;
8
+ const style = getComputedStyle(ancestor);
9
+ if (
10
+ style.display === 'none' ||
11
+ style.visibility === 'hidden' ||
12
+ style.visibility === 'collapse' ||
13
+ style.contentVisibility === 'hidden'
14
+ )
15
+ return false;
16
+ }
17
+ return element.tabIndex >= 0 || element.hasAttribute('tabindex') || element.isContentEditable;
18
+ }
@@ -0,0 +1,5 @@
1
+ export interface CredentialSecret {
2
+ id: string;
3
+ label: string;
4
+ value: string;
5
+ }