@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,4 +1,4 @@
1
- import { Trash2 } from '@gntik-ai/icons';
1
+ import { Lock, Trash2 } from '@gntik-ai/icons';
2
2
  import {
3
3
  AlertDialog,
4
4
  AlertDialogCancel,
@@ -11,9 +11,46 @@ import {
11
11
  Button,
12
12
  Field,
13
13
  FieldLabel,
14
+ EmptyState,
14
15
  Input,
16
+ useI18n,
15
17
  } from '@gntik-ai/ui';
16
- import { useState, type FormEvent, type ReactNode } from 'react';
18
+ import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
19
+ import { ErrorPanel } from '../ErrorPanel/ErrorPanel';
20
+ import { NotFoundEmpty } from '../EmptyStates/EmptyStates';
21
+
22
+ export type ConfirmDestructiveFailure =
23
+ | { kind: 'server'; title: string; message?: string; status?: number; requestId?: string }
24
+ | { kind: 'network'; title: string; message?: string }
25
+ | { kind: 'no-access' | 'not-found'; title: string; description?: string };
26
+
27
+ function normalizeFailure(value: unknown): ConfirmDestructiveFailure | null {
28
+ if (!value || typeof value !== 'object' || !('kind' in value) || !('title' in value)) return null;
29
+ if (typeof value.title !== 'string' || !value.title.trim()) return null;
30
+ const title = value.title;
31
+ const message = 'message' in value && typeof value.message === 'string' ? value.message : undefined;
32
+ if (value.kind === 'server') {
33
+ return {
34
+ kind: 'server',
35
+ title,
36
+ message,
37
+ status:
38
+ 'status' in value && typeof value.status === 'number' && Number.isFinite(value.status)
39
+ ? value.status
40
+ : undefined,
41
+ requestId: 'requestId' in value && typeof value.requestId === 'string' ? value.requestId : undefined,
42
+ };
43
+ }
44
+ if (value.kind === 'network') return { kind: 'network', title, message };
45
+ if (value.kind === 'no-access' || value.kind === 'not-found') {
46
+ return {
47
+ kind: value.kind,
48
+ title,
49
+ description: 'description' in value && typeof value.description === 'string' ? value.description : undefined,
50
+ };
51
+ }
52
+ return null;
53
+ }
17
54
 
18
55
  export interface ConfirmDestructiveProps {
19
56
  /** Exact name the user must type to unlock the action. */
@@ -33,6 +70,12 @@ export interface ConfirmDestructiveProps {
33
70
  onOpenChange?: (open: boolean) => void;
34
71
  /** Runs on confirm. Returning a promise keeps the dialog open with a spinner until it settles; a rejection shows its message. */
35
72
  onConfirm?: () => void | Promise<void>;
73
+ /** Extra fields inside the form, outside its accessible description. */
74
+ children?: ReactNode;
75
+ /** Additional consumer validity; the exact typed-name match is always required. */
76
+ canConfirm?: boolean;
77
+ /** Focus destination when there is no internal trigger. Defaults to the opener. */
78
+ returnFocusRef?: RefObject<HTMLElement | null>;
36
79
  }
37
80
 
38
81
  /**
@@ -55,47 +98,143 @@ export function ConfirmDestructive({
55
98
  defaultOpen = false,
56
99
  onOpenChange,
57
100
  onConfirm,
101
+ children,
102
+ canConfirm = true,
103
+ returnFocusRef,
58
104
  }: ConfirmDestructiveProps) {
105
+ const { t } = useI18n();
59
106
  const [innerOpen, setInnerOpen] = useState(defaultOpen);
60
107
  const [typed, setTyped] = useState('');
61
108
  const [pending, setPending] = useState(false);
109
+ const pendingRef = useRef(false);
110
+ const inputRef = useRef<HTMLInputElement>(null);
111
+ const popupRef = useRef<HTMLDivElement>(null);
112
+ const panelRef = useRef<HTMLDivElement>(null);
113
+ const cancelRef = useRef<HTMLButtonElement>(null);
114
+ const closeRef = useRef<HTMLButtonElement>(null);
115
+ const focusedControl = useRef<HTMLElement | null>(null);
116
+ const triggerRef = useRef<HTMLButtonElement>(null);
117
+ const previousFocus = useRef<HTMLElement | null>(null);
62
118
  const [error, setError] = useState<string | null>(null);
119
+ const [announcementId, setAnnouncementId] = useState(0);
120
+ const [failure, setFailure] = useState<ConfirmDestructiveFailure | null>(null);
63
121
  const isOpen = open ?? innerOpen;
122
+ const [previousOpen, setPreviousOpen] = useState(isOpen);
123
+ const session = useRef(0);
124
+ // Reset controlled closes during render so a reopened form never paints old values.
125
+ if (previousOpen !== isOpen) {
126
+ setPreviousOpen(isOpen);
127
+ if (!isOpen) {
128
+ setTyped('');
129
+ setError(null);
130
+ setFailure(null);
131
+ setPending(false);
132
+ }
133
+ }
134
+ useLayoutEffect(() => {
135
+ session.current = session.current + 1;
136
+ pendingRef.current = false;
137
+ focusedControl.current = null;
138
+ if (isOpen && document.activeElement instanceof HTMLElement) previousFocus.current = document.activeElement;
139
+ return () => {
140
+ session.current++;
141
+ };
142
+ }, [isOpen]);
64
143
  const label = confirmLabel ?? `Delete ${resourceType}`;
65
144
  const matches = typed === resourceName;
145
+ const terminal = failure?.kind === 'no-access' || failure?.kind === 'not-found' ? failure : null;
146
+
147
+ useLayoutEffect(() => {
148
+ if (!isOpen || !popupRef.current) return;
149
+ const popup = popupRef.current;
150
+ const available = (element: HTMLElement | null, keepLoading = false) =>
151
+ element?.isConnected &&
152
+ popup.contains(element) &&
153
+ !element.closest('[hidden], [inert], [aria-hidden="true"]') &&
154
+ (!element.matches(':disabled, [aria-disabled="true"]') ||
155
+ (keepLoading && element.getAttribute('aria-busy') === 'true'));
156
+ const restore = () => {
157
+ if (!focusedControl.current || available(focusedControl.current, true)) return;
158
+ // This composition disables secondary actions and details, so Retry is the last panel button.
159
+ const retry = panelRef.current?.querySelectorAll<HTMLButtonElement>('button');
160
+ const target = [
161
+ retry?.item(retry.length - 1) ?? null,
162
+ inputRef.current,
163
+ cancelRef.current,
164
+ closeRef.current,
165
+ ].find((element) => available(element));
166
+ target?.focus();
167
+ };
168
+ restore();
169
+ const observer = new MutationObserver(restore);
170
+ observer.observe(popup, {
171
+ childList: true,
172
+ subtree: true,
173
+ attributes: true,
174
+ attributeFilter: ['disabled', 'aria-disabled', 'hidden', 'inert', 'aria-hidden'],
175
+ });
176
+ return () => observer.disconnect();
177
+ });
66
178
 
67
179
  const setOpen = (next: boolean) => {
68
180
  if (!next) {
181
+ session.current++;
182
+ pendingRef.current = false;
183
+ setPending(false);
69
184
  setTyped('');
70
185
  setError(null);
186
+ setFailure(null);
71
187
  }
72
188
  setInnerOpen(next);
73
189
  onOpenChange?.(next);
74
190
  };
75
191
 
76
- const confirm = async (event: FormEvent) => {
77
- event.preventDefault();
78
- if (!matches || pending) return;
192
+ const confirm = async () => {
193
+ if (!isOpen || !matches || canConfirm === false || pendingRef.current || terminal) return;
194
+ const attemptSession = session.current;
195
+ pendingRef.current = true;
79
196
  setPending(true);
80
197
  setError(null);
81
198
  try {
82
199
  await onConfirm?.();
200
+ if (session.current !== attemptSession) return;
201
+ pendingRef.current = false;
83
202
  setPending(false);
84
203
  setOpen(false);
85
204
  } catch (err) {
205
+ if (session.current !== attemptSession) return;
206
+ pendingRef.current = false;
86
207
  setPending(false);
87
- setError(err instanceof Error ? err.message : 'Something went wrong. Try again.');
208
+ const next = normalizeFailure(err);
209
+ setAnnouncementId((id) => id + 1);
210
+ setFailure(next);
211
+ setError(
212
+ next
213
+ ? [next.title, 'message' in next ? next.message : 'description' in next ? next.description : undefined]
214
+ .filter(Boolean)
215
+ .join(' ')
216
+ : err instanceof Error
217
+ ? err.message
218
+ : 'Something went wrong. Try again.',
219
+ );
88
220
  }
89
221
  };
90
222
 
91
223
  return (
92
224
  <AlertDialog open={isOpen} onOpenChange={(next) => !pending && setOpen(next)}>
93
225
  {triggerLabel !== null && (
94
- <AlertDialogTrigger render={<Button variant="destructive" icon={Trash2} />}>
226
+ <AlertDialogTrigger ref={triggerRef} render={<Button variant="destructive" icon={Trash2} />}>
95
227
  {triggerLabel ?? label}
96
228
  </AlertDialogTrigger>
97
229
  )}
98
- <AlertDialogContent>
230
+ <AlertDialogContent
231
+ ref={popupRef}
232
+ initialFocus={inputRef}
233
+ finalFocus={() => triggerRef.current ?? returnFocusRef?.current ?? previousFocus.current ?? true}
234
+ onFocusCapture={(event) => {
235
+ if (event.target !== event.currentTarget) focusedControl.current = event.target;
236
+ }}
237
+ >
99
238
  <AlertDialogHeader tone="destructive">
100
239
  <AlertDialogTitle>{title ?? `Delete ${resourceType}?`}</AlertDialogTitle>
101
240
  <AlertDialogDescription>
@@ -107,36 +246,88 @@ export function ConfirmDestructive({
107
246
  )}
108
247
  </AlertDialogDescription>
109
248
  </AlertDialogHeader>
110
- {consequences.length > 0 && (
249
+ {!terminal && consequences.length > 0 && (
111
250
  <ul className="mt-4 list-disc space-y-1 rounded-md border border-destructive/25 bg-destructive/5 py-2.5 pe-3 ps-8 text-[12.5px] text-muted-foreground">
112
251
  {consequences.map((c) => (
113
252
  <li key={c}>{c}</li>
114
253
  ))}
115
254
  </ul>
116
255
  )}
117
- <form onSubmit={(e) => void confirm(e)} className="mt-4">
118
- <Field>
119
- <FieldLabel>
120
- Type <span className="font-mono font-semibold text-foreground">{resourceName}</span> to confirm
121
- </FieldLabel>
122
- <Input
123
- value={typed}
124
- onChange={(e) => setTyped(e.target.value)}
125
- autoComplete="off"
126
- spellCheck={false}
127
- disabled={pending}
128
- />
129
- </Field>
130
- {error && (
131
- <p role="alert" className="mt-2 text-[12.5px] text-destructive-text">
256
+ <form
257
+ onSubmit={(e) => {
258
+ e.preventDefault();
259
+ void confirm();
260
+ }}
261
+ className="mt-4"
262
+ >
263
+ {terminal ? (
264
+ terminal.kind === 'no-access' ? (
265
+ <EmptyState icon={Lock} title={terminal.title} description={terminal.description ?? null} />
266
+ ) : (
267
+ <NotFoundEmpty title={terminal.title} description={terminal.description ?? null} />
268
+ )
269
+ ) : (
270
+ <>
271
+ {children}
272
+ <Field>
273
+ <FieldLabel>
274
+ Type <span className="font-mono font-semibold text-foreground">{resourceName}</span> to confirm
275
+ </FieldLabel>
276
+ <Input
277
+ ref={inputRef}
278
+ value={typed}
279
+ onChange={(e) => setTyped(e.target.value)}
280
+ autoComplete="off"
281
+ spellCheck={false}
282
+ readOnly={pending}
283
+ />
284
+ </Field>
285
+ {(failure?.kind === 'server' || failure?.kind === 'network') && (
286
+ <div ref={panelRef}>
287
+ <ErrorPanel
288
+ title={failure.title}
289
+ message={failure.message ?? null}
290
+ code={failure.kind === 'server' ? (failure.status ?? null) : null}
291
+ requestId={failure.kind === 'server' ? (failure.requestId ?? '') : ''}
292
+ details=""
293
+ secondaryAction={null}
294
+ retrying={pending}
295
+ retryLabel={t('error.retry')}
296
+ onRetry={() => confirm()}
297
+ announce={false}
298
+ className="mt-4"
299
+ />
300
+ </div>
301
+ )}
302
+ </>
303
+ )}
304
+ {error !== null && (
305
+ <p
306
+ key={announcementId}
307
+ role="alert"
308
+ className={failure ? 'sr-only' : 'mt-2 text-[12.5px] text-destructive-text'}
309
+ >
132
310
  {error}
133
311
  </p>
134
312
  )}
135
313
  <AlertDialogFooter className="mt-5">
136
- <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
137
- <Button type="submit" variant="destructive" loading={pending} disabled={!matches}>
138
- {label}
139
- </Button>
314
+ {terminal ? (
315
+ <AlertDialogCancel ref={closeRef}>{t('common.close')}</AlertDialogCancel>
316
+ ) : (
317
+ <>
318
+ <AlertDialogCancel ref={cancelRef} disabled={pending}>
319
+ {t('common.cancel')}
320
+ </AlertDialogCancel>
321
+ <Button
322
+ type="submit"
323
+ variant="destructive"
324
+ loading={pending}
325
+ disabled={!matches || canConfirm === false}
326
+ >
327
+ {label}
328
+ </Button>
329
+ </>
330
+ )}
140
331
  </AlertDialogFooter>
141
332
  </form>
142
333
  </AlertDialogContent>
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
5
5
  family: 'feedback',
6
6
  status: 'beta',
7
7
  description:
8
- 'AlertDialog for irreversible actions that requires typing the resource name, with async confirm and error display.',
9
- uses: ['AlertDialog', 'Button', 'Field', 'Input'],
8
+ 'AlertDialog for irreversible actions with exact typed-name and optional canConfirm gating, extra form fields, typed failure states, guarded retry and focus return. See the plain deletion and secret revocation examples.',
9
+ uses: ['AlertDialog', 'Button', 'Field', 'Input', 'EmptyState'],
10
10
  };
@@ -0,0 +1,39 @@
1
+ import type { ConfirmDestructiveFailure } from '../ConfirmDestructive';
2
+ import SecretRevocation from './SecretRevocation';
3
+
4
+ // Consumers map HTTP and fetch failures; the dialog never infers a state from a status.
5
+ const server: ConfirmDestructiveFailure = {
6
+ kind: 'server',
7
+ title: 'Could not revoke version',
8
+ message: 'Try again after checking the service.',
9
+ status: 500,
10
+ requestId: 'req_revoke_7',
11
+ };
12
+ const network: ConfirmDestructiveFailure = {
13
+ kind: 'network',
14
+ title: 'Connection lost',
15
+ message: 'Check your connection and try again.',
16
+ };
17
+ const noAccess: ConfirmDestructiveFailure = {
18
+ kind: 'no-access',
19
+ title: 'You cannot revoke this version',
20
+ description: 'Ask an administrator to review your permissions.',
21
+ };
22
+ const notFound: ConfirmDestructiveFailure = {
23
+ kind: 'not-found',
24
+ title: 'Version not found',
25
+ description: 'This version has already been removed.',
26
+ };
27
+
28
+ export function ServerFailure() {
29
+ return <SecretRevocation onConfirm={() => Promise.reject(server)} />;
30
+ }
31
+ export function NetworkFailure() {
32
+ return <SecretRevocation onConfirm={() => Promise.reject(network)} />;
33
+ }
34
+ export function NoAccessFailure() {
35
+ return <SecretRevocation onConfirm={() => Promise.reject(noAccess)} />;
36
+ }
37
+ export function NotFoundFailure() {
38
+ return <SecretRevocation onConfirm={() => Promise.reject(notFound)} />;
39
+ }
@@ -0,0 +1,6 @@
1
+ import { ConfirmDestructive } from '../ConfirmDestructive';
2
+
3
+ /** Exact-name deletion; all optional extensions may be omitted. */
4
+ export default function PlainDeletion() {
5
+ return <ConfirmDestructive />;
6
+ }
@@ -0,0 +1,34 @@
1
+ import { Checkbox, Field, FieldLabel, Stack, Textarea } from '@gntik-ai/ui';
2
+ import { useState } from 'react';
3
+ import { ConfirmDestructive } from '../ConfirmDestructive';
4
+
5
+ /** Justification is optional here; use canConfirm to require it for a different policy. */
6
+ export default function SecretRevocation({ onConfirm }: { onConfirm?: () => void | Promise<void> }) {
7
+ const [justification, setJustification] = useState('');
8
+ const [forceRevoke, setForceRevoke] = useState(false);
9
+ return (
10
+ <ConfirmDestructive
11
+ resourceName="version-7"
12
+ resourceType="secret version"
13
+ title="Revoke secret version?"
14
+ confirmLabel="Revoke version"
15
+ description="This is the last valid version. Revoking it may interrupt services that still use it."
16
+ consequences={['Services must switch to a valid version before this one is revoked.']}
17
+ onConfirm={onConfirm}
18
+ onOpenChange={(open) => {
19
+ if (!open) {
20
+ setJustification('');
21
+ setForceRevoke(false);
22
+ }
23
+ }}
24
+ >
25
+ <Stack gap={3} className="mb-4">
26
+ <Field>
27
+ <FieldLabel>Justification</FieldLabel>
28
+ <Textarea value={justification} onChange={(event) => setJustification(event.target.value)} />
29
+ </Field>
30
+ <Checkbox label="Force revocation" checked={forceRevoke} onCheckedChange={setForceRevoke} />
31
+ </Stack>
32
+ </ConfirmDestructive>
33
+ );
34
+ }
@@ -1,9 +1,19 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { expectNoAxeViolations } from '../../test/a11y';
4
- import { FirstRunEmpty, NoAccessEmpty, NoResultsEmpty } from './EmptyStates';
4
+ import { FirstRunEmpty, NoAccessEmpty, NoResultsEmpty, NotFoundEmpty } from './EmptyStates';
5
5
 
6
6
  describe('EmptyStates', () => {
7
+ it('renders not found copy without actions or live regions, and accepts an action', async () => {
8
+ const { container, rerender } = render(<NotFoundEmpty title="Resource missing" description="It was deleted." />);
9
+ expect(screen.getByRole('heading', { name: 'Resource missing' })).toBeVisible();
10
+ expect(screen.getByText('It was deleted.')).toBeVisible();
11
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
12
+ expect(container.querySelector('[role="alert"], [role="status"], [aria-live]')).toBeNull();
13
+ await expectNoAxeViolations(container);
14
+ rerender(<NotFoundEmpty action={<button>Go back</button>} />);
15
+ expect(screen.getByRole('button', { name: 'Go back' })).toBeVisible();
16
+ });
7
17
  it('renders the three defaults', async () => {
8
18
  const { container } = render(
9
19
  <>
@@ -2,7 +2,7 @@ import { Check, FolderPlus, Lock, SearchX } from '@gntik-ai/icons';
2
2
  import type { LucideIcon } from '@gntik-ai/icons';
3
3
  import { Button, EmptyState, Link, cn } from '@gntik-ai/ui';
4
4
  import { useState, type ReactNode } from 'react';
5
- import { firstRunSteps, noAccessSample, noResultsSample } from './fixtures';
5
+ import { firstRunSteps, noAccessSample, noResultsSample, notFoundSample } from './fixtures';
6
6
 
7
7
  interface SharedEmptyProps {
8
8
  /** Heading level that fits the page outline. */
@@ -12,6 +12,36 @@ interface SharedEmptyProps {
12
12
  className?: string;
13
13
  }
14
14
 
15
+ export interface NotFoundEmptyProps extends SharedEmptyProps {
16
+ icon?: LucideIcon;
17
+ title?: ReactNode;
18
+ description?: ReactNode;
19
+ action?: ReactNode;
20
+ }
21
+
22
+ /** Missing resource state; consumers may supply an optional way back. */
23
+ export function NotFoundEmpty({
24
+ icon = SearchX,
25
+ title = notFoundSample.title,
26
+ description = notFoundSample.description,
27
+ action,
28
+ titleAs,
29
+ bordered = true,
30
+ className,
31
+ }: NotFoundEmptyProps) {
32
+ return (
33
+ <EmptyState
34
+ icon={icon}
35
+ title={title}
36
+ description={description}
37
+ primaryAction={action}
38
+ titleAs={titleAs}
39
+ bordered={bordered}
40
+ className={cn('mx-auto', className)}
41
+ />
42
+ );
43
+ }
44
+
15
45
  export interface FirstRunEmptyProps extends SharedEmptyProps {
16
46
  icon?: LucideIcon;
17
47
  title?: ReactNode;
@@ -5,6 +5,6 @@ export const meta: BlockMeta = {
5
5
  family: 'feedback',
6
6
  status: 'beta',
7
7
  description:
8
- 'Three canonical empty states: first run (with checklist), no results (clear filters/search) and no access (request access).',
8
+ 'Four canonical empty states: first run (with checklist), no results (clear filters/search), no access (request access) and not found (optional action).',
9
9
  uses: ['EmptyState', 'Button', 'Link'],
10
10
  };
@@ -4,3 +4,8 @@ export const firstRunSteps = ['Connect a Git repository', 'Configure build setti
4
4
  export const noResultsSample = { query: 'billing-api', filterCount: 2, entity: 'projects' } as const;
5
5
 
6
6
  export const noAccessSample = { resource: 'Billing', owner: 'an organization admin' } as const;
7
+
8
+ export const notFoundSample = {
9
+ title: 'Resource not found',
10
+ description: 'This resource may have been deleted or moved.',
11
+ } as const;
@@ -4,6 +4,13 @@ import { expectNoAxeViolations } from '../../test/a11y';
4
4
  import { ErrorPanel } from './ErrorPanel';
5
5
 
6
6
  describe('ErrorPanel', () => {
7
+ it('allows its caller to own announcements while preserving the default status region', () => {
8
+ const { rerender } = render(<ErrorPanel />);
9
+ expect(screen.getByRole('status')).toBeInTheDocument();
10
+ rerender(<ErrorPanel announce={false} />);
11
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
12
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
13
+ });
7
14
  it('accepts a null code to hide the fixture chip', () => {
8
15
  render(<ErrorPanel code={null} />);
9
16
  expect(screen.queryByText('503')).not.toBeInTheDocument();
@@ -24,6 +24,8 @@ export interface ErrorPanelProps {
24
24
  secondaryAction?: FeedbackAction | null;
25
25
  titleAs?: 'h2' | 'h3';
26
26
  className?: string;
27
+ /** Disable the status region when a parent owns announcements. */
28
+ announce?: boolean;
27
29
  }
28
30
 
29
31
  /** Error summary for a failed load or action: what happened, request id, retry and technical details. */
@@ -40,6 +42,7 @@ export function ErrorPanel({
40
42
  secondaryAction = { label: 'Contact support', variant: 'ghost' },
41
43
  titleAs: Heading = 'h3',
42
44
  className,
45
+ announce = true,
43
46
  }: ErrorPanelProps) {
44
47
  const { t } = useI18n();
45
48
  const retryLabel = retryLabelProp ?? t('error.retry');
@@ -136,9 +139,11 @@ export function ErrorPanel({
136
139
  </CollapsiblePanel>
137
140
  </Collapsible>
138
141
  )}
139
- <p role="status" className="sr-only">
140
- {busy ? t('error.retryingStatus') : copied ? t('error.requestIdCopied') : ''}
141
- </p>
142
+ {announce && (
143
+ <p role="status" className="sr-only">
144
+ {busy ? t('error.retryingStatus') : copied ? t('error.requestIdCopied') : ''}
145
+ </p>
146
+ )}
142
147
  </div>
143
148
  </div>
144
149
  </section>