@artkit-ai/react 0.0.0-stage → 0.2.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 (103) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +145 -2
  3. package/dist/cache.d.ts +31 -0
  4. package/dist/cache.d.ts.map +1 -0
  5. package/dist/cache.js +95 -0
  6. package/dist/cache.js.map +1 -0
  7. package/dist/client.d.ts +68 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +233 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/components/card.d.ts +22 -0
  12. package/dist/components/card.d.ts.map +1 -0
  13. package/dist/components/card.js +16 -0
  14. package/dist/components/card.js.map +1 -0
  15. package/dist/components/editor.d.ts +41 -0
  16. package/dist/components/editor.d.ts.map +1 -0
  17. package/dist/components/editor.js +111 -0
  18. package/dist/components/editor.js.map +1 -0
  19. package/dist/components/icons.d.ts +8 -0
  20. package/dist/components/icons.d.ts.map +1 -0
  21. package/dist/components/icons.js +14 -0
  22. package/dist/components/icons.js.map +1 -0
  23. package/dist/components/panel.d.ts +31 -0
  24. package/dist/components/panel.d.ts.map +1 -0
  25. package/dist/components/panel.js +132 -0
  26. package/dist/components/panel.js.map +1 -0
  27. package/dist/components/shared.d.ts +67 -0
  28. package/dist/components/shared.d.ts.map +1 -0
  29. package/dist/components/shared.js +100 -0
  30. package/dist/components/shared.js.map +1 -0
  31. package/dist/components/view.d.ts +39 -0
  32. package/dist/components/view.d.ts.map +1 -0
  33. package/dist/components/view.js +76 -0
  34. package/dist/components/view.js.map +1 -0
  35. package/dist/context.d.ts +64 -0
  36. package/dist/context.d.ts.map +1 -0
  37. package/dist/context.js +13 -0
  38. package/dist/context.js.map +1 -0
  39. package/dist/controls.d.ts +145 -0
  40. package/dist/controls.d.ts.map +1 -0
  41. package/dist/controls.js +188 -0
  42. package/dist/controls.js.map +1 -0
  43. package/dist/download.d.ts +4 -0
  44. package/dist/download.d.ts.map +1 -0
  45. package/dist/download.js +20 -0
  46. package/dist/download.js.map +1 -0
  47. package/dist/editor-session.d.ts +96 -0
  48. package/dist/editor-session.d.ts.map +1 -0
  49. package/dist/editor-session.js +192 -0
  50. package/dist/editor-session.js.map +1 -0
  51. package/dist/hooks.d.ts +64 -0
  52. package/dist/hooks.d.ts.map +1 -0
  53. package/dist/hooks.js +205 -0
  54. package/dist/hooks.js.map +1 -0
  55. package/dist/i18n.d.ts +135 -0
  56. package/dist/i18n.d.ts.map +1 -0
  57. package/dist/i18n.js +145 -0
  58. package/dist/i18n.js.map +1 -0
  59. package/dist/index.d.ts +19 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +18 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/kit-css.d.ts +3 -0
  64. package/dist/kit-css.d.ts.map +1 -0
  65. package/dist/kit-css.js +5 -0
  66. package/dist/kit-css.js.map +1 -0
  67. package/dist/partial-json.d.ts +8 -0
  68. package/dist/partial-json.d.ts.map +1 -0
  69. package/dist/partial-json.js +176 -0
  70. package/dist/partial-json.js.map +1 -0
  71. package/dist/provider.d.ts +42 -0
  72. package/dist/provider.d.ts.map +1 -0
  73. package/dist/provider.js +95 -0
  74. package/dist/provider.js.map +1 -0
  75. package/dist/styles.d.ts +25 -0
  76. package/dist/styles.d.ts.map +1 -0
  77. package/dist/styles.js +203 -0
  78. package/dist/styles.js.map +1 -0
  79. package/dist/toolbar.d.ts +90 -0
  80. package/dist/toolbar.d.ts.map +1 -0
  81. package/dist/toolbar.js +233 -0
  82. package/dist/toolbar.js.map +1 -0
  83. package/package.json +50 -3
  84. package/src/cache.ts +109 -0
  85. package/src/client.ts +342 -0
  86. package/src/components/card.tsx +86 -0
  87. package/src/components/editor.tsx +292 -0
  88. package/src/components/icons.tsx +14 -0
  89. package/src/components/panel.tsx +334 -0
  90. package/src/components/shared.tsx +198 -0
  91. package/src/components/view.tsx +196 -0
  92. package/src/context.ts +87 -0
  93. package/src/controls.tsx +587 -0
  94. package/src/download.ts +23 -0
  95. package/src/editor-session.ts +260 -0
  96. package/src/hooks.ts +296 -0
  97. package/src/i18n.ts +155 -0
  98. package/src/index.ts +128 -0
  99. package/src/kit-css.ts +5 -0
  100. package/src/partial-json.ts +160 -0
  101. package/src/provider.tsx +176 -0
  102. package/src/styles.ts +222 -0
  103. package/src/toolbar.tsx +536 -0
@@ -0,0 +1,292 @@
1
+ import type { Artifact, EditorActions, EmitOptions } from '@artkit-ai/core';
2
+ import { Redo2, Save, Undo2 } from 'lucide-react';
3
+ import { type KeyboardEvent, Suspense, use, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
4
+ import { type ArtkitContextValue, useArtkit } from '../context.js';
5
+ import { DraftSession, type DraftSnapshot } from '../editor-session.js';
6
+ import { useArtifact } from '../hooks.js';
7
+ import { hashString } from '../styles.js';
8
+ import { cx, ErrorBoundary, ErrorMessage, Loading, rootAttributes, type Slots, slot } from './shared.js';
9
+ import { ArtifactView, renderContext } from './view.js';
10
+
11
+ /**
12
+ * What the application receives when the user clicks Save. The library does not store anything: the application
13
+ * decides what saving means (a new version, a database row, a sync to another system, nothing at all).
14
+ */
15
+ export interface SaveEvent {
16
+ artifactId: string;
17
+ format: string;
18
+ /** Version the draft started from. Pass it back as `baseVersion` when you apply `operations` yourself. */
19
+ baseVersion: number;
20
+ title: string;
21
+ locale: string;
22
+ themeId: string;
23
+ /** Full content of the draft. */
24
+ content: unknown;
25
+ /** Operations applied since `baseVersion`, in order. */
26
+ operations: unknown[];
27
+ }
28
+
29
+ export type ArtifactEditorPart = 'toolbar' | 'status' | 'body';
30
+
31
+ export interface ArtifactEditorProps {
32
+ /** Artifact to edit. Its content is the starting point of the draft. */
33
+ artifact?: Artifact;
34
+ /** Load the artifact by id through the client when `artifact` is not given. */
35
+ id?: string;
36
+ /** Read-only edition: the format editor is hidden and the view is shown instead. */
37
+ readOnly?: boolean;
38
+ /** Save action. Called with the draft; the application persists it and passes the saved artifact back. */
39
+ onSave?(event: SaveEvent): void | Promise<void>;
40
+ /** Called after every change of the draft. */
41
+ onChange?(snapshot: DraftSnapshot): void;
42
+ className?: string;
43
+ slots?: Slots<ArtifactEditorPart>;
44
+ }
45
+
46
+ /**
47
+ * Editor of one artifact (spec §11.6). Edits are applied to a local draft; nothing reaches the server. The Save button
48
+ * emits the draft through `onSave`, and Ctrl/Cmd+S does the same. Undo and redo are local.
49
+ */
50
+ export function ArtifactEditor(props: ArtifactEditorProps) {
51
+ const ctx = useArtkit();
52
+ const fetched = useArtifact(props.artifact ? undefined : props.id);
53
+ const artifact = props.artifact ?? fetched.artifact;
54
+
55
+ if (!artifact) {
56
+ return (
57
+ <div className={cx('ak-root ak-editor', props.className)} {...rootAttributes(ctx)}>
58
+ {fetched.error ? (
59
+ <ErrorMessage error={fetched.error} onRetry={() => void fetched.refresh().catch(() => undefined)} />
60
+ ) : (
61
+ <Loading />
62
+ )}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ const editable = !props.readOnly && ctx.hasEditor(artifact.format) && ctx.registry.has(artifact.format);
68
+ if (!editable) {
69
+ return (
70
+ <div
71
+ className={cx('ak-root ak-editor', props.className)}
72
+ {...rootAttributes(ctx, artifact.themeId)}
73
+ data-readonly="true"
74
+ >
75
+ <ArtifactView artifact={artifact} />
76
+ </div>
77
+ );
78
+ }
79
+ return <EditingHost key={artifact.id} {...props} artifact={artifact} />;
80
+ }
81
+
82
+ function EditingHost({
83
+ artifact,
84
+ readOnly,
85
+ onSave,
86
+ onChange,
87
+ className,
88
+ slots,
89
+ }: ArtifactEditorProps & { artifact: Artifact }) {
90
+ const ctx = useArtkit();
91
+ const [session] = useState(
92
+ () => new DraftSession({ registry: ctx.registry, artifact, locale: ctx.locale }),
93
+ );
94
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
95
+ const [saving, setSaving] = useState(false);
96
+ const [saveError, setSaveError] = useState<unknown>();
97
+
98
+ // A newer artifact from the application (after a save, or a reload) moves the draft forward.
99
+ useEffect(() => {
100
+ if (
101
+ artifact.version !== session.getSnapshot().baseVersion ||
102
+ artifact.id !== session.getSnapshot().artifact.id
103
+ ) {
104
+ session.load(artifact);
105
+ }
106
+ }, [artifact, session]);
107
+
108
+ useEffect(() => {
109
+ onChange?.(snapshot);
110
+ }, [snapshot, onChange]);
111
+
112
+ const draft = snapshot.artifact;
113
+ const canSave = Boolean(onSave) && snapshot.dirty && !saving && !readOnly;
114
+
115
+ async function save() {
116
+ if (!canSave || !onSave) return;
117
+ setSaving(true);
118
+ setSaveError(undefined);
119
+ try {
120
+ await onSave({
121
+ artifactId: draft.id,
122
+ format: draft.format,
123
+ baseVersion: snapshot.baseVersion,
124
+ title: draft.title,
125
+ locale: draft.locale,
126
+ themeId: draft.themeId,
127
+ content: draft.content,
128
+ operations: snapshot.operations,
129
+ });
130
+ } catch (error) {
131
+ setSaveError(error);
132
+ } finally {
133
+ setSaving(false);
134
+ }
135
+ }
136
+
137
+ function onKeyDown(event: KeyboardEvent<HTMLElement>) {
138
+ if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
139
+ const key = event.key.toLowerCase();
140
+ if (key === 's') {
141
+ event.preventDefault();
142
+ void save();
143
+ } else if (key === 'z' && !event.shiftKey) {
144
+ event.preventDefault();
145
+ session.undo();
146
+ } else if (key === 'y' || (key === 'z' && event.shiftKey)) {
147
+ event.preventDefault();
148
+ session.redo();
149
+ }
150
+ }
151
+
152
+ const emit = (operations: unknown[], options?: EmitOptions) => {
153
+ void session.apply(operations, { ...(options?.kind ? { kind: options.kind } : {}) });
154
+ };
155
+
156
+ const actions: EditorActions = {
157
+ canUndo: snapshot.canUndo,
158
+ canRedo: snapshot.canRedo,
159
+ undo: () => session.undo(),
160
+ redo: () => session.redo(),
161
+ dirty: snapshot.dirty,
162
+ saving,
163
+ canSave,
164
+ save: () => void save(),
165
+ };
166
+
167
+ return (
168
+ // biome-ignore lint/a11y/noStaticElementInteractions: the shortcuts (Ctrl/Cmd+S, Z, Y) cover the whole editor.
169
+ <section
170
+ className={cx('ak-root ak-editor', className)}
171
+ {...rootAttributes(ctx, draft.themeId)}
172
+ data-dirty={snapshot.dirty || undefined}
173
+ onKeyDown={onKeyDown}
174
+ >
175
+ {snapshot.error ? <ErrorMessage error={snapshot.error} onDismiss={() => session.undo()} /> : null}
176
+ {saveError ? <ErrorMessage error={saveError} /> : null}
177
+
178
+ <div {...slot(slots, 'body', 'ak-editor-body')}>
179
+ <ErrorBoundary fallback={(error) => <ErrorMessage error={error} />}>
180
+ <Suspense fallback={<Loading />}>
181
+ <LoadedEditor
182
+ slots={slots}
183
+ actions={actions}
184
+ format={draft.format}
185
+ content={draft.content}
186
+ ctx={ctx}
187
+ themeId={draft.themeId}
188
+ locale={draft.locale}
189
+ emit={emit}
190
+ readOnly={Boolean(readOnly)}
191
+ lint={snapshot.lint}
192
+ />
193
+ </Suspense>
194
+ </ErrorBoundary>
195
+ </div>
196
+ </section>
197
+ );
198
+ }
199
+
200
+ function LoadedEditor({
201
+ format,
202
+ content,
203
+ ctx,
204
+ themeId,
205
+ locale,
206
+ emit,
207
+ readOnly,
208
+ lint,
209
+ actions,
210
+ slots,
211
+ }: {
212
+ slots?: Slots<ArtifactEditorPart>;
213
+ actions: EditorActions;
214
+ format: string;
215
+ content: unknown;
216
+ ctx: ArtkitContextValue;
217
+ themeId: string;
218
+ locale: string;
219
+ emit(operations: unknown[], options?: EmitOptions): void;
220
+ readOnly: boolean;
221
+ lint: DraftSnapshot['lint'];
222
+ }) {
223
+ const { Editor, css, toolbar } = use(ctx.loadEditor(format));
224
+ const renderCtx = useMemo(
225
+ () => renderContext(ctx, themeId, locale, 'screen', false),
226
+ [ctx, themeId, locale],
227
+ );
228
+ return (
229
+ <>
230
+ {css ? (
231
+ <style href={`artkit-editor-${format}-${hashString(css)}`} precedence="artkit" nonce={ctx.nonce}>
232
+ {css}
233
+ </style>
234
+ ) : null}
235
+ {toolbar === 'format' ? null : <HostToolbar actions={actions} slots={slots} />}
236
+ <Editor
237
+ content={content}
238
+ ctx={renderCtx}
239
+ emit={emit}
240
+ readOnly={readOnly}
241
+ lint={lint}
242
+ actions={actions}
243
+ />
244
+ </>
245
+ );
246
+ }
247
+
248
+ /** Undo, redo, save status and save, drawn by the host above editors that do not draw their own toolbar. */
249
+ function HostToolbar({ actions, slots }: { actions: EditorActions; slots?: Slots<ArtifactEditorPart> }) {
250
+ const { t } = useArtkit();
251
+ return (
252
+ <header {...slot(slots, 'toolbar', 'ak-editor-toolbar')}>
253
+ <div className="ak-editor-history">
254
+ <button
255
+ type="button"
256
+ className="ak-button"
257
+ data-variant="quiet"
258
+ disabled={!actions.canUndo}
259
+ onClick={actions.undo}
260
+ aria-label={t('undo')}
261
+ title={t('undo')}
262
+ >
263
+ <Undo2 aria-hidden="true" />
264
+ </button>
265
+ <button
266
+ type="button"
267
+ className="ak-button"
268
+ data-variant="quiet"
269
+ disabled={!actions.canRedo}
270
+ onClick={actions.redo}
271
+ aria-label={t('redo')}
272
+ title={t('redo')}
273
+ >
274
+ <Redo2 aria-hidden="true" />
275
+ </button>
276
+ </div>
277
+ <span {...slot(slots, 'status', 'ak-editor-status')} role="status" aria-live="polite">
278
+ {actions.dirty ? t('unsaved') : t('saved')}
279
+ </span>
280
+ <button
281
+ type="button"
282
+ className="ak-button"
283
+ data-variant="primary"
284
+ disabled={!actions.canSave}
285
+ onClick={actions.save}
286
+ >
287
+ <Save aria-hidden="true" />
288
+ {actions.saving ? t('saving') : t('save')}
289
+ </button>
290
+ </header>
291
+ );
292
+ }
@@ -0,0 +1,14 @@
1
+ import { File, FileText, type LucideIcon, Presentation, Sheet, Workflow } from 'lucide-react';
2
+
3
+ /** Lucide icon per native kind; unknown kinds get a generic file. */
4
+ export const KIND_ICONS: Record<string, LucideIcon> = {
5
+ document: FileText,
6
+ sheet: Sheet,
7
+ slides: Presentation,
8
+ diagram: Workflow,
9
+ };
10
+
11
+ export function KindIcon({ format, className }: { format: string; className?: string }) {
12
+ const Icon = KIND_ICONS[format] ?? File;
13
+ return <Icon aria-hidden="true" focusable="false" className={className} />;
14
+ }
@@ -0,0 +1,334 @@
1
+ import type { Artifact } from '@artkit-ai/core';
2
+ import { ChevronDown, Copy, Download, 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 { useArtkit } from '../context.js';
14
+ import type { DraftSnapshot } from '../editor-session.js';
15
+ import { useArtifact, useArtifactExport } from '../hooks.js';
16
+ import type { UiMessageKey } from '../i18n.js';
17
+ import { ArtifactEditor, type ArtifactEditorProps } from './editor.js';
18
+ import {
19
+ cx,
20
+ ErrorMessage,
21
+ formatLabel,
22
+ Loading,
23
+ rootAttributes,
24
+ type Slots,
25
+ slot,
26
+ useAnnouncer,
27
+ } from './shared.js';
28
+ import { ArtifactView } from './view.js';
29
+
30
+ export type PanelTab = 'view' | 'edit' | 'code';
31
+ export type ArtifactPanelPart = 'header' | 'title' | 'toolbar' | 'tabs' | 'body' | 'code';
32
+
33
+ export interface ArtifactPanelProps {
34
+ id?: string;
35
+ artifact?: Artifact;
36
+ tab?: PanelTab;
37
+ defaultTab?: PanelTab;
38
+ onTabChange?(tab: PanelTab): void;
39
+ /** Save action of the editor tab: the application decides what saving means. */
40
+ onSave?: ArtifactEditorProps['onSave'];
41
+ /** Draft changes, forwarded from the editor tab. */
42
+ onChange?(snapshot: DraftSnapshot): void;
43
+ /** Export formats; defaults to the provider's server formats plus browser exporters. */
44
+ exportFormats?: string[];
45
+ onClose?(): void;
46
+ /** Extra controls rendered in the header. */
47
+ headerActions?: ReactNode;
48
+ className?: string;
49
+ slots?: Slots<ArtifactPanelPart>;
50
+ }
51
+
52
+ const TABS: PanelTab[] = ['view', 'edit', 'code'];
53
+ const TAB_LABEL: Record<PanelTab, UiMessageKey> = { view: 'tabView', edit: 'tabEdit', code: 'tabCode' };
54
+
55
+ /**
56
+ * Side panel of one artifact: View, Edit and Code tabs and the export menu (spec §13.1). There is no history, approval
57
+ * or archive here: the application owns versions and publication.
58
+ */
59
+ export function ArtifactPanel(props: ArtifactPanelProps) {
60
+ const ctx = useArtkit();
61
+ const { t } = ctx;
62
+ const id = props.id ?? props.artifact?.id;
63
+ const latestQuery = useArtifact(id, props.artifact ? { initial: props.artifact } : {});
64
+ const latest = latestQuery.artifact;
65
+
66
+ const [ownTab, setOwnTab] = useState<PanelTab>(props.defaultTab ?? 'view');
67
+ const tab = props.tab ?? ownTab;
68
+ const selectTab = (next: PanelTab) => {
69
+ setOwnTab(next);
70
+ props.onTabChange?.(next);
71
+ };
72
+ const exporter = useArtifactExport(id ?? '', latest?.format);
73
+ const [live, announce] = useAnnouncer();
74
+ const baseId = useId();
75
+
76
+ if (!latest || !id) {
77
+ return (
78
+ <aside className={cx('ak-root ak-panel', props.className)} {...rootAttributes(ctx)}>
79
+ {latestQuery.error ? (
80
+ <ErrorMessage
81
+ error={latestQuery.error}
82
+ onRetry={() => void latestQuery.refresh().catch(() => undefined)}
83
+ />
84
+ ) : (
85
+ <Loading />
86
+ )}
87
+ </aside>
88
+ );
89
+ }
90
+
91
+ async function exportAs(format: string) {
92
+ try {
93
+ const result = await exporter.exportArtifact(format, {});
94
+ announce(t('exported', { filename: result.filename }));
95
+ } catch {
96
+ // The hook keeps the error; it is rendered below.
97
+ }
98
+ }
99
+
100
+ const tabId = (name: PanelTab) => `${baseId}-tab-${name}`;
101
+ const panelId = (name: PanelTab) => `${baseId}-panel-${name}`;
102
+ const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
103
+ const index = TABS.indexOf(tab);
104
+ let next: PanelTab | undefined;
105
+ if (event.key === 'ArrowRight') next = TABS[(index + 1) % TABS.length];
106
+ else if (event.key === 'ArrowLeft') next = TABS[(index + TABS.length - 1) % TABS.length];
107
+ else if (event.key === 'Home') next = TABS[0];
108
+ else if (event.key === 'End') next = TABS[TABS.length - 1];
109
+ if (!next) return;
110
+ event.preventDefault();
111
+ selectTab(next);
112
+ document.getElementById(tabId(next))?.focus();
113
+ };
114
+
115
+ const exportFormats = props.exportFormats ?? exporter.formats;
116
+
117
+ return (
118
+ <aside
119
+ className={cx('ak-root ak-panel', props.className)}
120
+ {...rootAttributes(ctx, latest.themeId)}
121
+ aria-labelledby={`${baseId}-title`}
122
+ data-format={latest.format}
123
+ >
124
+ {live}
125
+ <header {...slot(props.slots, 'header', 'ak-panel-header')}>
126
+ <h2 id={`${baseId}-title`} {...slot(props.slots, 'title', 'ak-panel-title')}>
127
+ {latest.title}
128
+ </h2>
129
+ <span className="ak-history-meta">{formatLabel(ctx, latest.format)}</span>
130
+ <span className="ak-badge">{t('versionShort', { version: latest.version })}</span>
131
+ {props.headerActions}
132
+ <ExportMenu
133
+ formats={exportFormats}
134
+ pending={exporter.pending}
135
+ onOpen={() => void exporter.loadFormats()}
136
+ onSelect={(format) => void exportAs(format)}
137
+ />
138
+ {props.onClose ? (
139
+ <button
140
+ type="button"
141
+ className="ak-button"
142
+ data-variant="quiet"
143
+ onClick={props.onClose}
144
+ aria-label={t('close')}
145
+ >
146
+ <X aria-hidden="true" />
147
+ </button>
148
+ ) : null}
149
+ </header>
150
+ {exporter.error ? <ErrorMessage error={exporter.error} className="ak-panel-notice" /> : null}
151
+
152
+ <div role="tablist" aria-label={latest.title} {...slot(props.slots, 'tabs', 'ak-tabs')}>
153
+ {TABS.map((name) => (
154
+ <button
155
+ key={name}
156
+ id={tabId(name)}
157
+ type="button"
158
+ role="tab"
159
+ className="ak-tab"
160
+ aria-selected={tab === name}
161
+ aria-controls={panelId(name)}
162
+ tabIndex={tab === name ? 0 : -1}
163
+ onClick={() => selectTab(name)}
164
+ onKeyDown={onTabKeyDown}
165
+ >
166
+ {t(TAB_LABEL[name])}
167
+ </button>
168
+ ))}
169
+ </div>
170
+
171
+ <div
172
+ id={panelId(tab)}
173
+ role="tabpanel"
174
+ aria-labelledby={tabId(tab)}
175
+ {...slot(props.slots, 'body', 'ak-panel-body')}
176
+ >
177
+ {tab === 'view' ? <ArtifactView artifact={latest} /> : null}
178
+ {tab === 'edit' ? (
179
+ <ArtifactEditor
180
+ artifact={latest}
181
+ {...(props.onSave ? { onSave: props.onSave } : {})}
182
+ {...(props.onChange ? { onChange: props.onChange } : {})}
183
+ />
184
+ ) : null}
185
+ {tab === 'code' ? <CodeTab artifact={latest} slots={props.slots} /> : null}
186
+ </div>
187
+ </aside>
188
+ );
189
+ }
190
+
191
+ function CodeTab({
192
+ artifact,
193
+ slots,
194
+ }: {
195
+ artifact: Artifact | undefined;
196
+ slots?: Slots<ArtifactPanelPart> | undefined;
197
+ }) {
198
+ const { t } = useArtkit();
199
+ const [copied, setCopied] = useState(false);
200
+ const text = useMemo(() => (artifact ? JSON.stringify(artifact.content, null, 2) : ''), [artifact]);
201
+ if (!artifact) return <Loading />;
202
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
203
+ return (
204
+ <div className="ak-code-tab">
205
+ {clipboard ? (
206
+ <button
207
+ type="button"
208
+ className="ak-button"
209
+ onClick={() => {
210
+ clipboard.writeText(text).then(
211
+ () => setCopied(true),
212
+ () => setCopied(false),
213
+ );
214
+ }}
215
+ >
216
+ <Copy aria-hidden="true" />
217
+ {copied ? t('copied') : t('copy')}
218
+ </button>
219
+ ) : null}
220
+ <pre {...slot(slots, 'code', 'ak-code')}>
221
+ <code>{text}</code>
222
+ </pre>
223
+ </div>
224
+ );
225
+ }
226
+
227
+ function ExportMenu(props: {
228
+ formats: string[];
229
+ pending: string | undefined;
230
+ onOpen(): void;
231
+ onSelect(format: string): void;
232
+ }) {
233
+ const { t } = useArtkit();
234
+ const [open, setOpen] = useState(false);
235
+ const button = useRef<HTMLButtonElement>(null);
236
+ const list = useRef<HTMLDivElement>(null);
237
+ const menuId = useId();
238
+
239
+ const items = useCallback(
240
+ () => [...(list.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])],
241
+ [],
242
+ );
243
+ useEffect(() => {
244
+ if (open) items()[0]?.focus();
245
+ }, [open, items]);
246
+
247
+ const close = (refocus: boolean) => {
248
+ setOpen(false);
249
+ if (refocus) button.current?.focus();
250
+ };
251
+
252
+ const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
253
+ const all = items();
254
+ const index = all.indexOf(document.activeElement as HTMLButtonElement);
255
+ let next: HTMLButtonElement | undefined;
256
+ if (event.key === 'ArrowDown') next = all[(index + 1) % all.length];
257
+ else if (event.key === 'ArrowUp') next = all[(index - 1 + all.length) % all.length];
258
+ else if (event.key === 'Home') next = all[0];
259
+ else if (event.key === 'End') next = all[all.length - 1];
260
+ else if (event.key === 'Escape') {
261
+ event.preventDefault();
262
+ close(true);
263
+ return;
264
+ } else if (event.key === 'Tab') {
265
+ close(false);
266
+ return;
267
+ }
268
+ if (next) {
269
+ event.preventDefault();
270
+ next.focus();
271
+ }
272
+ };
273
+
274
+ return (
275
+ <div className="ak-menu">
276
+ <button
277
+ ref={button}
278
+ type="button"
279
+ className="ak-button"
280
+ aria-haspopup="menu"
281
+ aria-expanded={open}
282
+ aria-controls={open ? menuId : undefined}
283
+ disabled={props.pending !== undefined}
284
+ onClick={() => {
285
+ if (!open) props.onOpen();
286
+ setOpen(!open);
287
+ }}
288
+ onKeyDown={(event) => {
289
+ if (event.key === 'ArrowDown' && !open) {
290
+ event.preventDefault();
291
+ props.onOpen();
292
+ setOpen(true);
293
+ }
294
+ }}
295
+ >
296
+ <Download aria-hidden="true" />
297
+ {props.pending ? t('exporting') : t('export')}
298
+ <ChevronDown aria-hidden="true" />
299
+ </button>
300
+ {open ? (
301
+ <div
302
+ ref={list}
303
+ id={menuId}
304
+ role="menu"
305
+ className="ak-menu-list"
306
+ aria-label={t('export')}
307
+ tabIndex={-1}
308
+ onKeyDown={onMenuKeyDown}
309
+ >
310
+ {props.formats.length === 0 ? (
311
+ <p className="ak-menu-item">{t('noExportFormats')}</p>
312
+ ) : (
313
+ props.formats.map((format) => (
314
+ <button
315
+ key={format}
316
+ type="button"
317
+ role="menuitem"
318
+ className="ak-menu-item"
319
+ tabIndex={-1}
320
+ aria-label={t('exportAs', { format: format.toUpperCase() })}
321
+ onClick={() => {
322
+ close(true);
323
+ props.onSelect(format);
324
+ }}
325
+ >
326
+ {format.toUpperCase()}
327
+ </button>
328
+ ))
329
+ )}
330
+ </div>
331
+ ) : null}
332
+ </div>
333
+ );
334
+ }