@artkit-ai/react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +145 -2
  3. package/dist/cache.d.ts +35 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +116 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/client.d.ts +79 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +260 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/components/approval.d.ts +34 -0
  12. package/dist/components/approval.d.ts.map +1 -0
  13. package/dist/components/approval.js +80 -0
  14. package/dist/components/approval.js.map +1 -0
  15. package/dist/components/card.d.ts +22 -0
  16. package/dist/components/card.d.ts.map +1 -0
  17. package/dist/components/card.js +16 -0
  18. package/dist/components/card.js.map +1 -0
  19. package/dist/components/editor.d.ts +28 -0
  20. package/dist/components/editor.d.ts.map +1 -0
  21. package/dist/components/editor.js +161 -0
  22. package/dist/components/editor.js.map +1 -0
  23. package/dist/components/icons.d.ts +8 -0
  24. package/dist/components/icons.d.ts.map +1 -0
  25. package/dist/components/icons.js +14 -0
  26. package/dist/components/icons.js.map +1 -0
  27. package/dist/components/panel.d.ts +34 -0
  28. package/dist/components/panel.d.ts.map +1 -0
  29. package/dist/components/panel.js +216 -0
  30. package/dist/components/panel.js.map +1 -0
  31. package/dist/components/shared.d.ts +71 -0
  32. package/dist/components/shared.d.ts.map +1 -0
  33. package/dist/components/shared.js +104 -0
  34. package/dist/components/shared.js.map +1 -0
  35. package/dist/components/version-diff.d.ts +35 -0
  36. package/dist/components/version-diff.d.ts.map +1 -0
  37. package/dist/components/version-diff.js +85 -0
  38. package/dist/components/version-diff.js.map +1 -0
  39. package/dist/components/view.d.ts +40 -0
  40. package/dist/components/view.d.ts.map +1 -0
  41. package/dist/components/view.js +75 -0
  42. package/dist/components/view.js.map +1 -0
  43. package/dist/context.d.ts +59 -0
  44. package/dist/context.d.ts.map +1 -0
  45. package/dist/context.js +13 -0
  46. package/dist/context.js.map +1 -0
  47. package/dist/controls.d.ts +145 -0
  48. package/dist/controls.d.ts.map +1 -0
  49. package/dist/controls.js +188 -0
  50. package/dist/controls.js.map +1 -0
  51. package/dist/download.d.ts +4 -0
  52. package/dist/download.d.ts.map +1 -0
  53. package/dist/download.js +20 -0
  54. package/dist/download.js.map +1 -0
  55. package/dist/editor-session.d.ts +184 -0
  56. package/dist/editor-session.d.ts.map +1 -0
  57. package/dist/editor-session.js +614 -0
  58. package/dist/editor-session.js.map +1 -0
  59. package/dist/hooks.d.ts +85 -0
  60. package/dist/hooks.d.ts.map +1 -0
  61. package/dist/hooks.js +297 -0
  62. package/dist/hooks.js.map +1 -0
  63. package/dist/i18n.d.ts +197 -0
  64. package/dist/i18n.d.ts.map +1 -0
  65. package/dist/i18n.js +207 -0
  66. package/dist/i18n.js.map +1 -0
  67. package/dist/index.d.ts +22 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +22 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/kit-css.d.ts +3 -0
  72. package/dist/kit-css.d.ts.map +1 -0
  73. package/dist/kit-css.js +5 -0
  74. package/dist/kit-css.js.map +1 -0
  75. package/dist/line-diff.d.ts +25 -0
  76. package/dist/line-diff.d.ts.map +1 -0
  77. package/dist/line-diff.js +96 -0
  78. package/dist/line-diff.js.map +1 -0
  79. package/dist/partial-json.d.ts +8 -0
  80. package/dist/partial-json.d.ts.map +1 -0
  81. package/dist/partial-json.js +176 -0
  82. package/dist/partial-json.js.map +1 -0
  83. package/dist/provider.d.ts +42 -0
  84. package/dist/provider.d.ts.map +1 -0
  85. package/dist/provider.js +95 -0
  86. package/dist/provider.js.map +1 -0
  87. package/dist/styles.d.ts +25 -0
  88. package/dist/styles.d.ts.map +1 -0
  89. package/dist/styles.js +203 -0
  90. package/dist/styles.js.map +1 -0
  91. package/dist/toolbar.d.ts +90 -0
  92. package/dist/toolbar.d.ts.map +1 -0
  93. package/dist/toolbar.js +233 -0
  94. package/dist/toolbar.js.map +1 -0
  95. package/package.json +50 -3
  96. package/src/cache.ts +132 -0
  97. package/src/client.ts +390 -0
  98. package/src/components/approval.tsx +219 -0
  99. package/src/components/card.tsx +87 -0
  100. package/src/components/editor.tsx +392 -0
  101. package/src/components/icons.tsx +14 -0
  102. package/src/components/panel.tsx +591 -0
  103. package/src/components/shared.tsx +207 -0
  104. package/src/components/version-diff.tsx +266 -0
  105. package/src/components/view.tsx +199 -0
  106. package/src/context.ts +82 -0
  107. package/src/controls.tsx +587 -0
  108. package/src/download.ts +23 -0
  109. package/src/editor-session.ts +738 -0
  110. package/src/hooks.ts +411 -0
  111. package/src/i18n.ts +217 -0
  112. package/src/index.ts +160 -0
  113. package/src/kit-css.ts +5 -0
  114. package/src/line-diff.ts +111 -0
  115. package/src/partial-json.ts +160 -0
  116. package/src/provider.tsx +176 -0
  117. package/src/styles.ts +222 -0
  118. package/src/toolbar.tsx +536 -0
@@ -0,0 +1,591 @@
1
+ import type { Actor, Artifact, ArtifactStatus, VersionEntry } from '@artkit-ai/core';
2
+ import { Archive, Check, ChevronDown, Copy, Download, Eye, History, Rocket, Send, X } from 'lucide-react';
3
+ import {
4
+ type KeyboardEvent,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useId,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import type { StatusAction } from '../client.js';
14
+ import { useArtkit } from '../context.js';
15
+ import { useArtifact, useArtifactExport, useArtifactHistory } from '../hooks.js';
16
+ import type { UiMessageKey } from '../i18n.js';
17
+ import { RejectForm } from './approval.js';
18
+ import { ArtifactEditor, type EditorPermission } from './editor.js';
19
+ import {
20
+ ChangeList,
21
+ cx,
22
+ ErrorMessage,
23
+ formatDate,
24
+ kindLabel,
25
+ Loading,
26
+ rootAttributes,
27
+ type Slots,
28
+ StatusBadge,
29
+ slot,
30
+ useAnnouncer,
31
+ } from './shared.js';
32
+ import { ArtifactView } from './view.js';
33
+
34
+ export type PanelTab = 'view' | 'code' | 'history';
35
+ export type ArtifactPanelPart = 'header' | 'title' | 'toolbar' | 'tabs' | 'body' | 'code' | 'history';
36
+
37
+ export interface ArtifactPanelProps {
38
+ id?: string;
39
+ artifact?: Artifact;
40
+ tab?: PanelTab;
41
+ defaultTab?: PanelTab;
42
+ onTabChange?(tab: PanelTab): void;
43
+ /** Selected version (undefined = latest). Controlled when `onVersionChange` is given with it. */
44
+ version?: number;
45
+ onVersionChange?(version: number | undefined): void;
46
+ /** `edit` shows the kind editor in the View tab for the latest version (default `read`). */
47
+ permission?: EditorPermission;
48
+ /** Streaming preview and lock state, forwarded to the view/editor. */
49
+ partial?: unknown;
50
+ lockedBy?: Actor | null;
51
+ /** Lifecycle actions offered; defaults depend on the status. `false` hides them. */
52
+ actions?: StatusAction[] | false;
53
+ /** Export formats; defaults to the provider's server formats plus browser exporters. */
54
+ exportFormats?: string[];
55
+ onClose?(): void;
56
+ /** Extra controls rendered in the header. */
57
+ headerActions?: ReactNode;
58
+ className?: string;
59
+ slots?: Slots<ArtifactPanelPart>;
60
+ }
61
+
62
+ const DEFAULT_ACTIONS: Record<ArtifactStatus, StatusAction[]> = {
63
+ draft: ['submit', 'archive'],
64
+ pending_approval: ['approve', 'reject', 'archive'],
65
+ published: ['archive'],
66
+ archived: [],
67
+ };
68
+
69
+ const ACTION_FROM: Record<StatusAction, ArtifactStatus[]> = {
70
+ submit: ['draft'],
71
+ approve: ['pending_approval'],
72
+ reject: ['pending_approval'],
73
+ publish: ['draft', 'pending_approval'],
74
+ archive: ['draft', 'pending_approval', 'published'],
75
+ };
76
+
77
+ const TABS: PanelTab[] = ['view', 'code', 'history'];
78
+ const TAB_LABEL: Record<PanelTab, UiMessageKey> = { view: 'tabView', code: 'tabCode', history: 'tabHistory' };
79
+
80
+ /** Assistant-style side panel: View, Code and History tabs, version selector, export and lifecycle (spec §13.1). */
81
+ export function ArtifactPanel(props: ArtifactPanelProps) {
82
+ const ctx = useArtkit();
83
+ const { t, client, cache } = ctx;
84
+ const id = props.id ?? props.artifact?.id;
85
+ const latestQuery = useArtifact(id, props.artifact ? { initial: props.artifact } : {});
86
+ const latest = latestQuery.artifact;
87
+
88
+ const [ownTab, setOwnTab] = useState<PanelTab>(props.defaultTab ?? 'view');
89
+ const tab = props.tab ?? ownTab;
90
+ const selectTab = (next: PanelTab) => {
91
+ setOwnTab(next);
92
+ props.onTabChange?.(next);
93
+ };
94
+ const [ownVersion, setOwnVersion] = useState<number | undefined>(undefined);
95
+ const selectedVersion = props.onVersionChange ? props.version : (props.version ?? ownVersion);
96
+ const selectVersion = (next: number | undefined) => {
97
+ setOwnVersion(next);
98
+ props.onVersionChange?.(next);
99
+ };
100
+ const viewingLatest = selectedVersion === undefined || selectedVersion === latest?.version;
101
+ const selectedQuery = useArtifact(
102
+ viewingLatest ? undefined : id,
103
+ selectedVersion === undefined ? {} : { version: selectedVersion },
104
+ );
105
+ const shown = viewingLatest ? latest : selectedQuery.artifact;
106
+
107
+ const history = useArtifactHistory(id);
108
+ const exporter = useArtifactExport(id ?? '', latest?.kind);
109
+ const [live, announce] = useAnnouncer();
110
+ const [statusBusy, setStatusBusy] = useState<StatusAction | undefined>(undefined);
111
+ const [actionError, setActionError] = useState<unknown>(undefined);
112
+ const [rejecting, setRejecting] = useState(false);
113
+ const [reason, setReason] = useState('');
114
+ const baseId = useId();
115
+
116
+ if (!latest || !id) {
117
+ return (
118
+ <aside className={cx('ak-root ak-panel', props.className)} {...rootAttributes(ctx)}>
119
+ {latestQuery.error ? (
120
+ <ErrorMessage
121
+ error={latestQuery.error}
122
+ onRetry={() => void latestQuery.refresh().catch(() => undefined)}
123
+ />
124
+ ) : (
125
+ <Loading />
126
+ )}
127
+ </aside>
128
+ );
129
+ }
130
+
131
+ const actions = (props.actions === false ? [] : (props.actions ?? DEFAULT_ACTIONS[latest.status])).filter(
132
+ (action) => ACTION_FROM[action].includes(latest.status),
133
+ );
134
+
135
+ async function runAction(action: StatusAction, message?: string) {
136
+ if (!latest) return;
137
+ setStatusBusy(action);
138
+ setActionError(undefined);
139
+ try {
140
+ const updated = await client.setStatus(latest.id, action, message);
141
+ cache.put(updated);
142
+ announce(t('statusChanged', { status: t(`status_${updated.status}` as UiMessageKey) }));
143
+ setRejecting(false);
144
+ setReason('');
145
+ } catch (error) {
146
+ setActionError(error);
147
+ } finally {
148
+ setStatusBusy(undefined);
149
+ }
150
+ }
151
+
152
+ async function restore(version: number) {
153
+ try {
154
+ const result = await history.restore(version);
155
+ announce(t('restored', { version, newVersion: result.version }));
156
+ selectVersion(undefined);
157
+ } catch (error) {
158
+ setActionError(error);
159
+ }
160
+ }
161
+
162
+ async function exportAs(format: string) {
163
+ try {
164
+ const result = await exporter.exportArtifact(
165
+ format,
166
+ viewingLatest ? {} : { version: selectedVersion! },
167
+ );
168
+ announce(t('exported', { filename: result.filename }));
169
+ } catch {
170
+ // The hook keeps the error; it is rendered below.
171
+ }
172
+ }
173
+
174
+ const tabId = (name: PanelTab) => `${baseId}-tab-${name}`;
175
+ const panelId = (name: PanelTab) => `${baseId}-panel-${name}`;
176
+ const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
177
+ const index = TABS.indexOf(tab);
178
+ let next: PanelTab | undefined;
179
+ if (event.key === 'ArrowRight') next = TABS[(index + 1) % TABS.length];
180
+ else if (event.key === 'ArrowLeft') next = TABS[(index + TABS.length - 1) % TABS.length];
181
+ else if (event.key === 'Home') next = TABS[0];
182
+ else if (event.key === 'End') next = TABS[TABS.length - 1];
183
+ if (!next) return;
184
+ event.preventDefault();
185
+ selectTab(next);
186
+ document.getElementById(tabId(next))?.focus();
187
+ };
188
+
189
+ const versionOptions = mergeVersions(history.entries, latest.version);
190
+ const exportFormats = props.exportFormats ?? exporter.formats;
191
+
192
+ return (
193
+ <aside
194
+ className={cx('ak-root ak-panel', props.className)}
195
+ {...rootAttributes(ctx, latest.themeId)}
196
+ aria-labelledby={`${baseId}-title`}
197
+ data-kind={latest.kind}
198
+ >
199
+ {live}
200
+ <header {...slot(props.slots, 'header', 'ak-panel-header')}>
201
+ <h2 id={`${baseId}-title`} {...slot(props.slots, 'title', 'ak-panel-title')}>
202
+ {latest.title}
203
+ </h2>
204
+ <span className="ak-history-meta">{kindLabel(ctx, latest.kind)}</span>
205
+ <StatusBadge status={latest.status} />
206
+ {props.headerActions}
207
+ {props.onClose ? (
208
+ <button
209
+ type="button"
210
+ className="ak-button"
211
+ data-variant="quiet"
212
+ onClick={props.onClose}
213
+ aria-label={t('close')}
214
+ >
215
+ <X aria-hidden="true" />
216
+ </button>
217
+ ) : null}
218
+ </header>
219
+ <div {...slot(props.slots, 'toolbar', 'ak-panel-toolbar')}>
220
+ <label htmlFor={`${baseId}-version`}>{t('version')}</label>
221
+ <select
222
+ id={`${baseId}-version`}
223
+ className="ak-select"
224
+ value={viewingLatest ? '' : String(selectedVersion)}
225
+ onChange={(event) =>
226
+ selectVersion(event.target.value === '' ? undefined : Number(event.target.value))
227
+ }
228
+ >
229
+ <option value="">{`${t('versionShort', { version: latest.version })} (${t('latest')})`}</option>
230
+ {versionOptions
231
+ .filter((version) => version !== latest.version)
232
+ .map((version) => (
233
+ <option key={version} value={String(version)}>
234
+ {t('versionShort', { version })}
235
+ </option>
236
+ ))}
237
+ </select>
238
+ <ExportMenu
239
+ formats={exportFormats}
240
+ pending={exporter.pending}
241
+ onOpen={() => void exporter.loadFormats()}
242
+ onSelect={(format) => void exportAs(format)}
243
+ />
244
+ {actions.map((action) =>
245
+ action === 'reject' ? (
246
+ <button
247
+ key={action}
248
+ type="button"
249
+ className="ak-button"
250
+ data-variant="danger"
251
+ disabled={statusBusy !== undefined}
252
+ onClick={() => setRejecting(true)}
253
+ >
254
+ <X aria-hidden="true" />
255
+ {t('action_reject')}
256
+ </button>
257
+ ) : (
258
+ <button
259
+ key={action}
260
+ type="button"
261
+ className="ak-button"
262
+ data-variant={
263
+ action === 'approve' || action === 'submit' || action === 'publish' ? 'primary' : undefined
264
+ }
265
+ disabled={statusBusy !== undefined}
266
+ onClick={() => void runAction(action)}
267
+ >
268
+ <ActionIcon action={action} />
269
+ {t(`action_${action}` as UiMessageKey)}
270
+ </button>
271
+ ),
272
+ )}
273
+ </div>
274
+ {rejecting ? (
275
+ <div className="ak-panel-toolbar">
276
+ <RejectForm
277
+ busy={statusBusy !== undefined}
278
+ requireReason
279
+ reason={reason}
280
+ onReasonChange={setReason}
281
+ onSubmit={() => void runAction('reject', reason.trim())}
282
+ onCancel={() => setRejecting(false)}
283
+ />
284
+ </div>
285
+ ) : null}
286
+ {actionError ? <ErrorMessage error={actionError} onDismiss={() => setActionError(undefined)} /> : null}
287
+ {exporter.error ? <ErrorMessage error={exporter.error} /> : null}
288
+ {exporter.warnings.length > 0 ? (
289
+ <div className="ak-notice" role="status">
290
+ {t('exportWarnings')}
291
+ <ul>
292
+ {exporter.warnings.map((warning) => (
293
+ <li key={warning}>{warning}</li>
294
+ ))}
295
+ </ul>
296
+ </div>
297
+ ) : null}
298
+ <div {...slot(props.slots, 'tabs', 'ak-tabs')} role="tablist" aria-label={t('tabsLabel')}>
299
+ {TABS.map((name) => (
300
+ <button
301
+ key={name}
302
+ id={tabId(name)}
303
+ type="button"
304
+ role="tab"
305
+ className="ak-tab"
306
+ aria-selected={tab === name}
307
+ aria-controls={panelId(name)}
308
+ tabIndex={tab === name ? 0 : -1}
309
+ onClick={() => selectTab(name)}
310
+ onKeyDown={onTabKeyDown}
311
+ >
312
+ {t(TAB_LABEL[name])}
313
+ </button>
314
+ ))}
315
+ </div>
316
+ <div
317
+ {...slot(props.slots, 'body', 'ak-panel-body')}
318
+ role="tabpanel"
319
+ id={panelId(tab)}
320
+ aria-labelledby={tabId(tab)}
321
+ >
322
+ {tab === 'view' ? (
323
+ viewingLatest && (props.permission === 'edit' || props.lockedBy) ? (
324
+ <ArtifactEditor
325
+ artifact={latest}
326
+ permission={props.permission ?? 'read'}
327
+ {...(props.lockedBy ? { lockedBy: props.lockedBy } : {})}
328
+ {...(props.partial === undefined ? {} : { partial: props.partial })}
329
+ />
330
+ ) : shown ? (
331
+ <ArtifactView
332
+ artifact={shown}
333
+ {...(viewingLatest && props.partial !== undefined ? { partial: props.partial } : {})}
334
+ />
335
+ ) : selectedQuery.error ? (
336
+ <ErrorMessage error={selectedQuery.error} />
337
+ ) : (
338
+ <Loading />
339
+ )
340
+ ) : null}
341
+ {tab === 'code' ? <CodeTab artifact={shown} slots={props.slots} /> : null}
342
+ {tab === 'history' ? (
343
+ <HistoryTab
344
+ history={history}
345
+ latestVersion={latest.version}
346
+ selectedVersion={viewingLatest ? latest.version : selectedVersion}
347
+ slots={props.slots}
348
+ onView={(version) => {
349
+ selectVersion(version === latest.version ? undefined : version);
350
+ selectTab('view');
351
+ }}
352
+ onRestore={(version) => void restore(version)}
353
+ />
354
+ ) : null}
355
+ </div>
356
+ </aside>
357
+ );
358
+ }
359
+
360
+ function mergeVersions(entries: VersionEntry[], latest: number): number[] {
361
+ const versions = new Set<number>([latest, ...entries.map((entry) => entry.version)]);
362
+ return [...versions].sort((a, b) => b - a);
363
+ }
364
+
365
+ function CodeTab({
366
+ artifact,
367
+ slots,
368
+ }: {
369
+ artifact: Artifact | undefined;
370
+ slots?: Slots<ArtifactPanelPart> | undefined;
371
+ }) {
372
+ const { t } = useArtkit();
373
+ const [copied, setCopied] = useState(false);
374
+ const text = useMemo(() => (artifact ? JSON.stringify(artifact.content, null, 2) : ''), [artifact]);
375
+ if (!artifact) return <Loading />;
376
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
377
+ return (
378
+ <div className="ak-code-tab">
379
+ {clipboard ? (
380
+ <button
381
+ type="button"
382
+ className="ak-button"
383
+ onClick={() => {
384
+ clipboard.writeText(text).then(
385
+ () => setCopied(true),
386
+ () => setCopied(false),
387
+ );
388
+ }}
389
+ >
390
+ <Copy aria-hidden="true" />
391
+ {copied ? t('copied') : t('copy')}
392
+ </button>
393
+ ) : null}
394
+ <pre {...slot(slots, 'code', 'ak-code')}>
395
+ <code>{text}</code>
396
+ </pre>
397
+ </div>
398
+ );
399
+ }
400
+
401
+ function HistoryTab(props: {
402
+ history: ReturnType<typeof useArtifactHistory>;
403
+ latestVersion: number;
404
+ selectedVersion: number | undefined;
405
+ onView(version: number): void;
406
+ onRestore(version: number): void;
407
+ slots?: Slots<ArtifactPanelPart> | undefined;
408
+ }) {
409
+ const ctx = useArtkit();
410
+ const { t } = ctx;
411
+ const { history } = props;
412
+ if (history.error && history.entries.length === 0)
413
+ return <ErrorMessage error={history.error} onRetry={() => void history.refresh()} />;
414
+ if (history.loading && history.entries.length === 0) return <Loading />;
415
+ if (history.entries.length === 0) return <p className="ak-notice">{t('historyEmpty')}</p>;
416
+ return (
417
+ <div>
418
+ <ol {...slot(props.slots, 'history', 'ak-history')} reversed>
419
+ {history.entries.map((entry) => (
420
+ <li
421
+ key={entry.version}
422
+ className="ak-history-entry"
423
+ aria-current={entry.version === props.selectedVersion ? 'true' : undefined}
424
+ >
425
+ <div className="ak-history-head">
426
+ <strong>{t('versionShort', { version: entry.version })}</strong>
427
+ <span className="ak-history-meta">
428
+ {formatDate(entry.createdAt, ctx.locale)} ·{' '}
429
+ {t('by', { actor: t(`actor_${entry.actor.type}` as UiMessageKey) })}
430
+ {entry.restoredFrom !== undefined
431
+ ? ` · ${t('restoredFrom', { version: entry.restoredFrom })}`
432
+ : ''}
433
+ </span>
434
+ <span className="ak-history-actions">
435
+ <button
436
+ type="button"
437
+ className="ak-button"
438
+ aria-label={t('viewVersion', { version: entry.version })}
439
+ onClick={() => props.onView(entry.version)}
440
+ >
441
+ <Eye aria-hidden="true" />
442
+ {t('tabView')}
443
+ </button>
444
+ {entry.version !== props.latestVersion ? (
445
+ <button
446
+ type="button"
447
+ className="ak-button"
448
+ aria-label={t('restoreVersion', { version: entry.version })}
449
+ disabled={history.restoring !== undefined}
450
+ onClick={() => props.onRestore(entry.version)}
451
+ >
452
+ <History aria-hidden="true" />
453
+ {t('restore')}
454
+ </button>
455
+ ) : null}
456
+ </span>
457
+ </div>
458
+ {entry.message ? <p className="ak-history-meta">{entry.message}</p> : null}
459
+ {entry.diff.length > 0 ? <ChangeList changes={entry.diff} /> : null}
460
+ </li>
461
+ ))}
462
+ </ol>
463
+ {history.hasMore ? (
464
+ <button
465
+ type="button"
466
+ className="ak-button"
467
+ disabled={history.loading}
468
+ onClick={() => void history.loadMore()}
469
+ >
470
+ {t('loadMore')}
471
+ </button>
472
+ ) : null}
473
+ </div>
474
+ );
475
+ }
476
+
477
+ function ExportMenu(props: {
478
+ formats: string[];
479
+ pending: string | undefined;
480
+ onOpen(): void;
481
+ onSelect(format: string): void;
482
+ }) {
483
+ const { t } = useArtkit();
484
+ const [open, setOpen] = useState(false);
485
+ const button = useRef<HTMLButtonElement>(null);
486
+ const list = useRef<HTMLDivElement>(null);
487
+ const menuId = useId();
488
+
489
+ const items = useCallback(
490
+ () => [...(list.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])],
491
+ [],
492
+ );
493
+ useEffect(() => {
494
+ if (open) items()[0]?.focus();
495
+ }, [open, items]);
496
+
497
+ const close = (refocus: boolean) => {
498
+ setOpen(false);
499
+ if (refocus) button.current?.focus();
500
+ };
501
+
502
+ const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
503
+ const all = items();
504
+ const index = all.indexOf(document.activeElement as HTMLButtonElement);
505
+ let next: HTMLButtonElement | undefined;
506
+ if (event.key === 'ArrowDown') next = all[(index + 1) % all.length];
507
+ else if (event.key === 'ArrowUp') next = all[(index - 1 + all.length) % all.length];
508
+ else if (event.key === 'Home') next = all[0];
509
+ else if (event.key === 'End') next = all[all.length - 1];
510
+ else if (event.key === 'Escape') {
511
+ event.preventDefault();
512
+ close(true);
513
+ return;
514
+ } else if (event.key === 'Tab') {
515
+ close(false);
516
+ return;
517
+ }
518
+ if (next) {
519
+ event.preventDefault();
520
+ next.focus();
521
+ }
522
+ };
523
+
524
+ return (
525
+ <div className="ak-menu">
526
+ <button
527
+ ref={button}
528
+ type="button"
529
+ className="ak-button"
530
+ aria-haspopup="menu"
531
+ aria-expanded={open}
532
+ aria-controls={open ? menuId : undefined}
533
+ disabled={props.pending !== undefined}
534
+ onClick={() => {
535
+ if (!open) props.onOpen();
536
+ setOpen(!open);
537
+ }}
538
+ onKeyDown={(event) => {
539
+ if (event.key === 'ArrowDown' && !open) {
540
+ event.preventDefault();
541
+ props.onOpen();
542
+ setOpen(true);
543
+ }
544
+ }}
545
+ >
546
+ <Download aria-hidden="true" />
547
+ {props.pending ? t('exporting') : t('export')}
548
+ <ChevronDown aria-hidden="true" />
549
+ </button>
550
+ {open ? (
551
+ <div
552
+ ref={list}
553
+ id={menuId}
554
+ role="menu"
555
+ className="ak-menu-list"
556
+ aria-label={t('export')}
557
+ tabIndex={-1}
558
+ onKeyDown={onMenuKeyDown}
559
+ >
560
+ {props.formats.length === 0 ? (
561
+ <p className="ak-menu-item">{t('noExportFormats')}</p>
562
+ ) : (
563
+ props.formats.map((format) => (
564
+ <button
565
+ key={format}
566
+ type="button"
567
+ role="menuitem"
568
+ className="ak-menu-item"
569
+ tabIndex={-1}
570
+ aria-label={t('exportAs', { format: format.toUpperCase() })}
571
+ onClick={() => {
572
+ close(true);
573
+ props.onSelect(format);
574
+ }}
575
+ >
576
+ {format.toUpperCase()}
577
+ </button>
578
+ ))
579
+ )}
580
+ </div>
581
+ ) : null}
582
+ </div>
583
+ );
584
+ }
585
+
586
+ const ACTION_ICONS = { submit: Send, approve: Check, publish: Rocket, archive: Archive } as const;
587
+
588
+ function ActionIcon({ action }: { action: string }) {
589
+ const Icon = ACTION_ICONS[action as keyof typeof ACTION_ICONS];
590
+ return Icon ? <Icon aria-hidden="true" /> : null;
591
+ }