@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.
- package/LICENSE +202 -0
- package/README.md +145 -2
- package/dist/cache.d.ts +31 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +95 -0
- package/dist/cache.js.map +1 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +233 -0
- package/dist/client.js.map +1 -0
- package/dist/components/card.d.ts +22 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.js +16 -0
- package/dist/components/card.js.map +1 -0
- package/dist/components/editor.d.ts +41 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +111 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/icons.d.ts +8 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +14 -0
- package/dist/components/icons.js.map +1 -0
- package/dist/components/panel.d.ts +31 -0
- package/dist/components/panel.d.ts.map +1 -0
- package/dist/components/panel.js +132 -0
- package/dist/components/panel.js.map +1 -0
- package/dist/components/shared.d.ts +67 -0
- package/dist/components/shared.d.ts.map +1 -0
- package/dist/components/shared.js +100 -0
- package/dist/components/shared.js.map +1 -0
- package/dist/components/view.d.ts +39 -0
- package/dist/components/view.d.ts.map +1 -0
- package/dist/components/view.js +76 -0
- package/dist/components/view.js.map +1 -0
- package/dist/context.d.ts +64 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +13 -0
- package/dist/context.js.map +1 -0
- package/dist/controls.d.ts +145 -0
- package/dist/controls.d.ts.map +1 -0
- package/dist/controls.js +188 -0
- package/dist/controls.js.map +1 -0
- package/dist/download.d.ts +4 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +20 -0
- package/dist/download.js.map +1 -0
- package/dist/editor-session.d.ts +96 -0
- package/dist/editor-session.d.ts.map +1 -0
- package/dist/editor-session.js +192 -0
- package/dist/editor-session.js.map +1 -0
- package/dist/hooks.d.ts +64 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +205 -0
- package/dist/hooks.js.map +1 -0
- package/dist/i18n.d.ts +135 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +145 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/kit-css.d.ts +3 -0
- package/dist/kit-css.d.ts.map +1 -0
- package/dist/kit-css.js +5 -0
- package/dist/kit-css.js.map +1 -0
- package/dist/partial-json.d.ts +8 -0
- package/dist/partial-json.d.ts.map +1 -0
- package/dist/partial-json.js +176 -0
- package/dist/partial-json.js.map +1 -0
- package/dist/provider.d.ts +42 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +95 -0
- package/dist/provider.js.map +1 -0
- package/dist/styles.d.ts +25 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +203 -0
- package/dist/styles.js.map +1 -0
- package/dist/toolbar.d.ts +90 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +233 -0
- package/dist/toolbar.js.map +1 -0
- package/package.json +50 -3
- package/src/cache.ts +109 -0
- package/src/client.ts +342 -0
- package/src/components/card.tsx +86 -0
- package/src/components/editor.tsx +292 -0
- package/src/components/icons.tsx +14 -0
- package/src/components/panel.tsx +334 -0
- package/src/components/shared.tsx +198 -0
- package/src/components/view.tsx +196 -0
- package/src/context.ts +87 -0
- package/src/controls.tsx +587 -0
- package/src/download.ts +23 -0
- package/src/editor-session.ts +260 -0
- package/src/hooks.ts +296 -0
- package/src/i18n.ts +155 -0
- package/src/index.ts +128 -0
- package/src/kit-css.ts +5 -0
- package/src/partial-json.ts +160 -0
- package/src/provider.tsx +176 -0
- package/src/styles.ts +222 -0
- package/src/toolbar.tsx +536 -0
package/src/controls.tsx
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { COLOR_TOKENS, type Color, isColorToken, resolveColor, type Theme } from '@artkit-ai/core';
|
|
2
|
+
import type { LucideIcon } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
type ButtonHTMLAttributes,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Standard form controls for kind editors and side panels (Mouts design: 32px controls, 6px radius, accent
|
|
14
|
+
* pressed state, focus ring). Every kind uses these, so buttons, fields, selects, switches and property
|
|
15
|
+
* panels look the same in documents, sheets, slides and diagrams, in light and dark themes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const pt = (locale: string | undefined) => (locale ?? '').toLowerCase().startsWith('pt');
|
|
19
|
+
|
|
20
|
+
export type ControlVariant = 'default' | 'primary' | 'quiet' | 'danger';
|
|
21
|
+
export type ControlSize = 'sm' | 'md';
|
|
22
|
+
|
|
23
|
+
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> {
|
|
24
|
+
icon?: LucideIcon;
|
|
25
|
+
variant?: ControlVariant;
|
|
26
|
+
size?: ControlSize;
|
|
27
|
+
/** Toggle state (`aria-pressed`). */
|
|
28
|
+
pressed?: boolean;
|
|
29
|
+
type?: 'button' | 'submit';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Button({
|
|
33
|
+
icon: Icon,
|
|
34
|
+
variant = 'default',
|
|
35
|
+
size = 'md',
|
|
36
|
+
pressed,
|
|
37
|
+
className,
|
|
38
|
+
children,
|
|
39
|
+
type = 'button',
|
|
40
|
+
...rest
|
|
41
|
+
}: ButtonProps): ReactElement {
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
type={type}
|
|
45
|
+
className={`ak-ctl-button${className ? ` ${className}` : ''}`}
|
|
46
|
+
data-variant={variant === 'default' ? undefined : variant}
|
|
47
|
+
data-size={size === 'md' ? undefined : size}
|
|
48
|
+
{...(pressed === undefined ? {} : { 'aria-pressed': pressed })}
|
|
49
|
+
{...rest}
|
|
50
|
+
>
|
|
51
|
+
{Icon ? <Icon aria-hidden="true" /> : null}
|
|
52
|
+
{children}
|
|
53
|
+
</button>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface IconButtonProps extends Omit<ButtonProps, 'icon' | 'children' | 'aria-label'> {
|
|
58
|
+
icon: LucideIcon;
|
|
59
|
+
label: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Square icon-only button; the label becomes the accessible name and tooltip. */
|
|
63
|
+
export function IconButton({ icon, label, title, ...rest }: IconButtonProps): ReactElement {
|
|
64
|
+
return <Button icon={icon} aria-label={label} title={title ?? label} data-icon-only="" {...rest} />;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FieldProps {
|
|
68
|
+
label: ReactNode;
|
|
69
|
+
hint?: ReactNode;
|
|
70
|
+
htmlFor?: string;
|
|
71
|
+
children: ReactNode;
|
|
72
|
+
className?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Label above a control (and an optional hint below it). */
|
|
76
|
+
export function Field({ label, hint, htmlFor, children, className }: FieldProps): ReactElement {
|
|
77
|
+
return (
|
|
78
|
+
<div className={`ak-ctl-field${className ? ` ${className}` : ''}`}>
|
|
79
|
+
<label className="ak-ctl-label" htmlFor={htmlFor}>
|
|
80
|
+
{label}
|
|
81
|
+
</label>
|
|
82
|
+
{children}
|
|
83
|
+
{hint ? <span className="ak-ctl-hint">{hint}</span> : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fields side by side, wrapping on narrow panels. */
|
|
89
|
+
export function FieldRow({ children, label }: { children: ReactNode; label?: string }): ReactElement {
|
|
90
|
+
return (
|
|
91
|
+
<div className="ak-ctl-row" {...(label ? { role: 'group', 'aria-label': label } : {})}>
|
|
92
|
+
{children}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface TextFieldProps {
|
|
98
|
+
label: string;
|
|
99
|
+
value: string;
|
|
100
|
+
onCommit(value: string): void;
|
|
101
|
+
multiline?: boolean;
|
|
102
|
+
disabled?: boolean;
|
|
103
|
+
placeholder?: string;
|
|
104
|
+
hint?: ReactNode;
|
|
105
|
+
rows?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Text input that commits on blur (or Enter for single-line fields), not on every keystroke. */
|
|
109
|
+
export function TextField({
|
|
110
|
+
label,
|
|
111
|
+
value,
|
|
112
|
+
onCommit,
|
|
113
|
+
multiline,
|
|
114
|
+
disabled,
|
|
115
|
+
placeholder,
|
|
116
|
+
hint,
|
|
117
|
+
rows = 4,
|
|
118
|
+
}: TextFieldProps): ReactElement {
|
|
119
|
+
const id = useId();
|
|
120
|
+
const [draft, setDraft] = useState(value);
|
|
121
|
+
useEffect(() => setDraft(value), [value]);
|
|
122
|
+
const commit = () => {
|
|
123
|
+
if (draft !== value) onCommit(draft);
|
|
124
|
+
};
|
|
125
|
+
return (
|
|
126
|
+
<Field label={label} htmlFor={id} hint={hint}>
|
|
127
|
+
{multiline ? (
|
|
128
|
+
<textarea
|
|
129
|
+
id={id}
|
|
130
|
+
className="ak-ctl-textarea"
|
|
131
|
+
value={draft}
|
|
132
|
+
disabled={disabled}
|
|
133
|
+
placeholder={placeholder}
|
|
134
|
+
rows={rows}
|
|
135
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
136
|
+
onBlur={commit}
|
|
137
|
+
/>
|
|
138
|
+
) : (
|
|
139
|
+
<input
|
|
140
|
+
id={id}
|
|
141
|
+
className="ak-ctl-input"
|
|
142
|
+
value={draft}
|
|
143
|
+
disabled={disabled}
|
|
144
|
+
placeholder={placeholder}
|
|
145
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
146
|
+
onBlur={commit}
|
|
147
|
+
onKeyDown={(event) => {
|
|
148
|
+
if (event.key === 'Enter') commit();
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</Field>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface NumberFieldProps {
|
|
157
|
+
label: string;
|
|
158
|
+
value: number | undefined;
|
|
159
|
+
onCommit(value: number): void;
|
|
160
|
+
min?: number;
|
|
161
|
+
max?: number;
|
|
162
|
+
step?: number;
|
|
163
|
+
disabled?: boolean;
|
|
164
|
+
placeholder?: string;
|
|
165
|
+
/** Unit shown after the value (e.g. "in", "pt", "%"). */
|
|
166
|
+
unit?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Number input that commits on blur or Enter, clamped to min/max. */
|
|
170
|
+
export function NumberField({
|
|
171
|
+
label,
|
|
172
|
+
value,
|
|
173
|
+
onCommit,
|
|
174
|
+
min,
|
|
175
|
+
max,
|
|
176
|
+
step = 0.01,
|
|
177
|
+
disabled,
|
|
178
|
+
placeholder,
|
|
179
|
+
unit,
|
|
180
|
+
}: NumberFieldProps): ReactElement {
|
|
181
|
+
const id = useId();
|
|
182
|
+
const [draft, setDraft] = useState(value === undefined ? '' : String(value));
|
|
183
|
+
useEffect(() => setDraft(value === undefined ? '' : String(value)), [value]);
|
|
184
|
+
const commit = () => {
|
|
185
|
+
const parsed = Number(draft);
|
|
186
|
+
if (draft.trim() === '' || !Number.isFinite(parsed)) {
|
|
187
|
+
setDraft(value === undefined ? '' : String(value));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const clamped = Math.max(min ?? -Infinity, Math.min(max ?? Infinity, parsed));
|
|
191
|
+
if (clamped !== value) onCommit(clamped);
|
|
192
|
+
setDraft(String(clamped));
|
|
193
|
+
};
|
|
194
|
+
const input = (
|
|
195
|
+
<input
|
|
196
|
+
id={id}
|
|
197
|
+
className="ak-ctl-input"
|
|
198
|
+
type="number"
|
|
199
|
+
inputMode="decimal"
|
|
200
|
+
value={draft}
|
|
201
|
+
min={min}
|
|
202
|
+
max={max}
|
|
203
|
+
step={step}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
placeholder={placeholder}
|
|
206
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
207
|
+
onBlur={commit}
|
|
208
|
+
onKeyDown={(event) => {
|
|
209
|
+
if (event.key === 'Enter') commit();
|
|
210
|
+
}}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
return (
|
|
214
|
+
<Field label={label} htmlFor={id}>
|
|
215
|
+
{unit ? (
|
|
216
|
+
<span className="ak-ctl-affix">
|
|
217
|
+
{input}
|
|
218
|
+
<span className="ak-ctl-unit" aria-hidden="true">
|
|
219
|
+
{unit}
|
|
220
|
+
</span>
|
|
221
|
+
</span>
|
|
222
|
+
) : (
|
|
223
|
+
input
|
|
224
|
+
)}
|
|
225
|
+
</Field>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface SelectFieldProps<T extends string> {
|
|
230
|
+
label: string;
|
|
231
|
+
value: T | undefined;
|
|
232
|
+
options: readonly { value: T; label: string }[];
|
|
233
|
+
onChange(value: T): void;
|
|
234
|
+
disabled?: boolean;
|
|
235
|
+
hint?: ReactNode;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function SelectField<T extends string>({
|
|
239
|
+
label,
|
|
240
|
+
value,
|
|
241
|
+
options,
|
|
242
|
+
onChange,
|
|
243
|
+
disabled,
|
|
244
|
+
hint,
|
|
245
|
+
}: SelectFieldProps<T>): ReactElement {
|
|
246
|
+
const id = useId();
|
|
247
|
+
return (
|
|
248
|
+
<Field label={label} htmlFor={id} hint={hint}>
|
|
249
|
+
<select
|
|
250
|
+
id={id}
|
|
251
|
+
className="ak-ctl-select"
|
|
252
|
+
value={value ?? ''}
|
|
253
|
+
disabled={disabled}
|
|
254
|
+
onChange={(event) => onChange(event.target.value as T)}
|
|
255
|
+
>
|
|
256
|
+
{value === undefined ? <option value="">—</option> : null}
|
|
257
|
+
{options.map((option) => (
|
|
258
|
+
<option key={option.value} value={option.value}>
|
|
259
|
+
{option.label}
|
|
260
|
+
</option>
|
|
261
|
+
))}
|
|
262
|
+
</select>
|
|
263
|
+
</Field>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface SwitchProps {
|
|
268
|
+
label: string;
|
|
269
|
+
checked: boolean;
|
|
270
|
+
onChange(value: boolean): void;
|
|
271
|
+
disabled?: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** On/off switch (`role="switch"`) with its label beside it. */
|
|
275
|
+
export function Switch({ label, checked, onChange, disabled }: SwitchProps): ReactElement {
|
|
276
|
+
return (
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
role="switch"
|
|
280
|
+
aria-checked={checked}
|
|
281
|
+
className="ak-ctl-switch"
|
|
282
|
+
disabled={disabled}
|
|
283
|
+
onClick={() => onChange(!checked)}
|
|
284
|
+
>
|
|
285
|
+
<span className="ak-ctl-switch-track" aria-hidden="true">
|
|
286
|
+
<span className="ak-ctl-switch-thumb" />
|
|
287
|
+
</span>
|
|
288
|
+
<span className="ak-ctl-switch-label">{label}</span>
|
|
289
|
+
</button>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface ToggleProps {
|
|
294
|
+
label: string;
|
|
295
|
+
pressed: boolean;
|
|
296
|
+
onChange(value: boolean): void;
|
|
297
|
+
disabled?: boolean;
|
|
298
|
+
/** Button content (e.g. an icon or "B"); without it the toggle is drawn as a switch. */
|
|
299
|
+
children?: ReactNode;
|
|
300
|
+
icon?: LucideIcon;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Pressed-state button (formatting toggles) or, without content, a labelled switch. */
|
|
304
|
+
export function Toggle({ label, pressed, onChange, disabled, children, icon }: ToggleProps): ReactElement {
|
|
305
|
+
if (children === undefined && icon === undefined)
|
|
306
|
+
return <Switch label={label} checked={pressed} onChange={onChange} {...(disabled ? { disabled } : {})} />;
|
|
307
|
+
return (
|
|
308
|
+
<Button
|
|
309
|
+
size="sm"
|
|
310
|
+
pressed={pressed}
|
|
311
|
+
aria-label={label}
|
|
312
|
+
title={label}
|
|
313
|
+
disabled={disabled}
|
|
314
|
+
data-icon-only=""
|
|
315
|
+
{...(icon ? { icon } : {})}
|
|
316
|
+
onClick={() => onChange(!pressed)}
|
|
317
|
+
>
|
|
318
|
+
{children}
|
|
319
|
+
</Button>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface SegmentedProps<T extends string> {
|
|
324
|
+
label: string;
|
|
325
|
+
value: T | undefined;
|
|
326
|
+
options: readonly { value: T; label: string; icon?: LucideIcon }[];
|
|
327
|
+
onChange(value: T): void;
|
|
328
|
+
disabled?: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Small set of mutually exclusive options shown as joined buttons. */
|
|
332
|
+
export function Segmented<T extends string>({
|
|
333
|
+
label,
|
|
334
|
+
value,
|
|
335
|
+
options,
|
|
336
|
+
onChange,
|
|
337
|
+
disabled,
|
|
338
|
+
}: SegmentedProps<T>): ReactElement {
|
|
339
|
+
return (
|
|
340
|
+
<div className="ak-ctl-segmented" role="radiogroup" aria-label={label}>
|
|
341
|
+
{options.map(({ value: option, label: text, icon: Icon }) => (
|
|
342
|
+
// biome-ignore lint/a11y/useSemanticElements: segmented buttons are styled radio buttons.
|
|
343
|
+
<button
|
|
344
|
+
key={option}
|
|
345
|
+
type="button"
|
|
346
|
+
role="radio"
|
|
347
|
+
aria-checked={value === option}
|
|
348
|
+
aria-label={Icon ? text : undefined}
|
|
349
|
+
title={text}
|
|
350
|
+
disabled={disabled}
|
|
351
|
+
onClick={() => onChange(option)}
|
|
352
|
+
>
|
|
353
|
+
{Icon ? <Icon aria-hidden="true" /> : text}
|
|
354
|
+
</button>
|
|
355
|
+
))}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface ColorPickerProps {
|
|
361
|
+
label: string;
|
|
362
|
+
value: Color | undefined;
|
|
363
|
+
theme: Theme;
|
|
364
|
+
locale: string;
|
|
365
|
+
onChange(color: Color | undefined): void;
|
|
366
|
+
/** Offer a hex input besides the theme tokens. */
|
|
367
|
+
allowHex?: boolean;
|
|
368
|
+
/** Offer a "none" option (for optional fills). */
|
|
369
|
+
allowNone?: boolean;
|
|
370
|
+
disabled?: boolean;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
374
|
+
|
|
375
|
+
/** Theme token swatches with an optional hex field. */
|
|
376
|
+
export function ColorPicker({
|
|
377
|
+
label,
|
|
378
|
+
value,
|
|
379
|
+
theme,
|
|
380
|
+
locale,
|
|
381
|
+
onChange,
|
|
382
|
+
allowHex,
|
|
383
|
+
allowNone,
|
|
384
|
+
disabled,
|
|
385
|
+
}: ColorPickerProps): ReactElement {
|
|
386
|
+
const [hex, setHex] = useState(value && !isColorToken(value) ? value : '');
|
|
387
|
+
useEffect(() => setHex(value && !isColorToken(value) ? value : ''), [value]);
|
|
388
|
+
const none = pt(locale) ? 'Nenhuma' : 'None';
|
|
389
|
+
return (
|
|
390
|
+
<fieldset className="ak-ctl-colors" disabled={disabled}>
|
|
391
|
+
<legend className="ak-ctl-label">{label}</legend>
|
|
392
|
+
<div className="ak-ctl-swatches">
|
|
393
|
+
{allowNone ? (
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
aria-pressed={value === undefined}
|
|
397
|
+
className="ak-ctl-swatch ak-ctl-swatch-none"
|
|
398
|
+
aria-label={none}
|
|
399
|
+
title={none}
|
|
400
|
+
onClick={() => onChange(undefined)}
|
|
401
|
+
/>
|
|
402
|
+
) : null}
|
|
403
|
+
{COLOR_TOKENS.map((token) => (
|
|
404
|
+
<button
|
|
405
|
+
key={token}
|
|
406
|
+
type="button"
|
|
407
|
+
aria-pressed={value === token}
|
|
408
|
+
className="ak-ctl-swatch"
|
|
409
|
+
style={{ background: resolveColor(theme, token) }}
|
|
410
|
+
aria-label={token}
|
|
411
|
+
title={token}
|
|
412
|
+
onClick={() => onChange(token)}
|
|
413
|
+
/>
|
|
414
|
+
))}
|
|
415
|
+
</div>
|
|
416
|
+
{allowHex ? (
|
|
417
|
+
<span className="ak-ctl-affix ak-ctl-hex">
|
|
418
|
+
<span
|
|
419
|
+
className="ak-ctl-hex-preview"
|
|
420
|
+
aria-hidden="true"
|
|
421
|
+
style={HEX.test(hex) ? { background: hex } : undefined}
|
|
422
|
+
/>
|
|
423
|
+
<input
|
|
424
|
+
className="ak-ctl-input"
|
|
425
|
+
data-size="sm"
|
|
426
|
+
aria-label={`${label} (hex)`}
|
|
427
|
+
placeholder="#RRGGBB"
|
|
428
|
+
value={hex}
|
|
429
|
+
onChange={(event) => setHex(event.target.value)}
|
|
430
|
+
onBlur={() => {
|
|
431
|
+
if (HEX.test(hex) && hex !== value) onChange(hex);
|
|
432
|
+
}}
|
|
433
|
+
onKeyDown={(event) => {
|
|
434
|
+
if (event.key === 'Enter' && HEX.test(hex)) onChange(hex);
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
</span>
|
|
438
|
+
) : null}
|
|
439
|
+
</fieldset>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface InspectorProps {
|
|
444
|
+
label: string;
|
|
445
|
+
children: ReactNode;
|
|
446
|
+
className?: string;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Side properties panel of an editor: stacked sections separated by rules. */
|
|
450
|
+
export function Inspector({ label, children, className }: InspectorProps): ReactElement {
|
|
451
|
+
return (
|
|
452
|
+
<aside className={`ak-ctl-inspector${className ? ` ${className}` : ''}`} aria-label={label}>
|
|
453
|
+
{children}
|
|
454
|
+
</aside>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export interface InspectorSectionProps {
|
|
459
|
+
title?: ReactNode;
|
|
460
|
+
/** Accessible name when the title is not plain text. */
|
|
461
|
+
label?: string;
|
|
462
|
+
actions?: ReactNode;
|
|
463
|
+
children: ReactNode;
|
|
464
|
+
className?: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function InspectorSection({
|
|
468
|
+
title,
|
|
469
|
+
label,
|
|
470
|
+
actions,
|
|
471
|
+
children,
|
|
472
|
+
className,
|
|
473
|
+
}: InspectorSectionProps): ReactElement {
|
|
474
|
+
const ariaLabel = label ?? (typeof title === 'string' ? title : undefined);
|
|
475
|
+
return (
|
|
476
|
+
<section
|
|
477
|
+
className={`ak-ctl-section${className ? ` ${className}` : ''}`}
|
|
478
|
+
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
|
479
|
+
>
|
|
480
|
+
{title || actions ? (
|
|
481
|
+
<header className="ak-ctl-section-head">
|
|
482
|
+
{title ? <h3 className="ak-ctl-section-title">{title}</h3> : null}
|
|
483
|
+
{actions ? <div className="ak-ctl-section-actions">{actions}</div> : null}
|
|
484
|
+
</header>
|
|
485
|
+
) : null}
|
|
486
|
+
{children}
|
|
487
|
+
</section>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Wrapping row of buttons. */
|
|
492
|
+
export function ButtonRow({ children, label }: { children: ReactNode; label?: string }): ReactElement {
|
|
493
|
+
return (
|
|
494
|
+
<div className="ak-ctl-buttons" {...(label ? { role: 'group', 'aria-label': label } : {})}>
|
|
495
|
+
{children}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Muted helper text for empty states and instructions. */
|
|
501
|
+
export function Hint({ children }: { children: ReactNode }): ReactElement {
|
|
502
|
+
return <p className="ak-ctl-empty">{children}</p>;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const BUTTON =
|
|
506
|
+
'display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 12px;margin:0;font:inherit;font-size:13px;font-weight:600;line-height:1;white-space:nowrap;border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-sm,6px);background:var(--ak-color-background,#fff);color:var(--ak-color-text,#1d2f3c);cursor:pointer;transition:background-color .15s,border-color .15s,color .15s,box-shadow .15s';
|
|
507
|
+
const FIELD =
|
|
508
|
+
'box-sizing:border-box;width:100%;min-width:0;height:32px;margin:0;padding:0 10px;font:inherit;font-size:13px;line-height:1.3;color:var(--ak-color-text,#1d2f3c);background:var(--ak-color-background,#fff);border:1px solid var(--ak-color-border-strong,#8e979d);border-radius:var(--ak-radius-sm,6px);transition:border-color .15s,box-shadow .15s';
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Plain `<button>`, `<input>`, `<select>` and `<textarea>` without a class inside `.ak-ctl-scope` get the
|
|
512
|
+
* standard look too (zero specificity, so any kind class still wins).
|
|
513
|
+
*/
|
|
514
|
+
const SCOPE = ':where(.ak-ctl-scope)';
|
|
515
|
+
const PLAIN_BUTTON = `${SCOPE} :where(button:not([class]))`;
|
|
516
|
+
const PLAIN_FIELD = `${SCOPE} :where(input:not([class]):not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="range"]):not([type="file"]),select:not([class]),textarea:not([class]))`;
|
|
517
|
+
|
|
518
|
+
export const controlsCss = `
|
|
519
|
+
.ak-ctl-button,${PLAIN_BUTTON}{${BUTTON}}
|
|
520
|
+
.ak-ctl-button svg,${PLAIN_BUTTON} svg{width:16px;height:16px;flex-shrink:0}
|
|
521
|
+
.ak-ctl-button:hover:not(:disabled),${PLAIN_BUTTON}:hover:not(:disabled){background:var(--ak-color-hover,#f2f3f4);border-color:var(--ak-color-border-strong,#8e979d)}
|
|
522
|
+
.ak-ctl-button:active:not(:disabled),${PLAIN_BUTTON}:active:not(:disabled){background:var(--ak-color-active,#e8eaec)}
|
|
523
|
+
.ak-ctl-button[aria-pressed="true"],${PLAIN_BUTTON}[aria-pressed="true"]{background:var(--ak-color-fill-accent,#e7f2ef);color:var(--ak-color-accent,#0f7a5a);border-color:var(--ak-color-accent,#0f7a5a)}
|
|
524
|
+
.ak-ctl-button:disabled,${PLAIN_BUTTON}:disabled{opacity:.4;cursor:not-allowed}
|
|
525
|
+
.ak-ctl-button:focus-visible,${PLAIN_BUTTON}:focus-visible,.ak-ctl-switch:focus-visible,.ak-ctl-swatch:focus-visible,.ak-ctl-segmented button:focus-visible{outline:2px solid var(--ak-color-focus,#0f7a5a);outline-offset:2px}
|
|
526
|
+
.ak-ctl-button[data-size="sm"]{height:28px;padding:0 10px;font-size:12px}
|
|
527
|
+
.ak-ctl-button[data-size="sm"] svg{width:14px;height:14px}
|
|
528
|
+
.ak-ctl-button[data-icon-only]{width:32px;padding:0}
|
|
529
|
+
.ak-ctl-button[data-icon-only][data-size="sm"]{width:28px}
|
|
530
|
+
.ak-ctl-button[data-variant="primary"]{border-color:transparent;background:var(--ak-gradient-button,#00dd80);color:var(--ak-color-on-primary,#1d2f3c)}
|
|
531
|
+
.ak-ctl-button[data-variant="primary"]:hover:not(:disabled){border-color:transparent;background:var(--ak-gradient-button-hover,#16a683)}
|
|
532
|
+
.ak-ctl-button[data-variant="quiet"]{border-color:transparent;background:transparent;color:var(--ak-color-muted,#56636d)}
|
|
533
|
+
.ak-ctl-button[data-variant="quiet"]:hover:not(:disabled){border-color:transparent;color:var(--ak-color-text,#1d2f3c)}
|
|
534
|
+
.ak-ctl-button[data-variant="quiet"][aria-pressed="true"]{border-color:var(--ak-color-accent,#0f7a5a);color:var(--ak-color-accent,#0f7a5a)}
|
|
535
|
+
.ak-ctl-button[data-variant="danger"]{color:var(--ak-color-danger,#9f343a)}
|
|
536
|
+
.ak-ctl-button[data-variant="danger"]:hover:not(:disabled){background:var(--ak-color-fill-danger,#f5ebeb);border-color:var(--ak-color-danger,#9f343a)}
|
|
537
|
+
.ak-ctl-input,.ak-ctl-select,.ak-ctl-textarea,${PLAIN_FIELD}{${FIELD}}
|
|
538
|
+
.ak-ctl-input:hover:not(:disabled),.ak-ctl-select:hover:not(:disabled),.ak-ctl-textarea:hover:not(:disabled),${PLAIN_FIELD}:hover:not(:disabled){border-color:var(--ak-color-muted,#56636d)}
|
|
539
|
+
.ak-ctl-input:focus,.ak-ctl-select:focus,.ak-ctl-textarea:focus,${PLAIN_FIELD}:focus{outline:none;border-color:var(--ak-color-accent,#0f7a5a);box-shadow:0 0 0 1px var(--ak-color-accent,#0f7a5a)}
|
|
540
|
+
.ak-ctl-input:disabled,.ak-ctl-select:disabled,.ak-ctl-textarea:disabled,${PLAIN_FIELD}:disabled{opacity:.5;cursor:not-allowed}
|
|
541
|
+
.ak-ctl-input::placeholder,.ak-ctl-textarea::placeholder{color:var(--ak-color-muted,#56636d);opacity:.8}
|
|
542
|
+
.ak-ctl-select,${SCOPE} :where(select:not([class])){cursor:pointer;padding-right:6px}
|
|
543
|
+
.ak-ctl-textarea,${SCOPE} :where(textarea:not([class])){height:auto;min-height:72px;padding:8px 10px;line-height:1.5;resize:vertical}
|
|
544
|
+
.ak-ctl-input[data-size="sm"],.ak-ctl-select[data-size="sm"]{height:28px;font-size:12px;padding:0 8px}
|
|
545
|
+
.ak-ctl-field{display:flex;flex-direction:column;gap:4px;min-width:0;margin:0}
|
|
546
|
+
.ak-ctl-label{display:block;padding:0;font-size:12px;font-weight:600;line-height:16px;color:var(--ak-color-muted,#56636d)}
|
|
547
|
+
.ak-ctl-hint{font-size:12px;line-height:16px;color:var(--ak-color-muted,#56636d)}
|
|
548
|
+
.ak-ctl-row{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end}
|
|
549
|
+
.ak-ctl-row>.ak-ctl-field{flex:1 1 64px}
|
|
550
|
+
.ak-ctl-buttons{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0;padding:0;border:0;min-inline-size:0}
|
|
551
|
+
.ak-ctl-affix{position:relative;display:flex;align-items:center;min-width:0}
|
|
552
|
+
.ak-ctl-affix .ak-ctl-input{padding-right:30px}
|
|
553
|
+
.ak-ctl-unit{position:absolute;right:8px;font-size:12px;color:var(--ak-color-muted,#56636d);pointer-events:none}
|
|
554
|
+
.ak-ctl-hex{max-width:160px}
|
|
555
|
+
.ak-ctl-hex .ak-ctl-input{padding-left:30px;padding-right:8px;font-family:var(--ak-font-mono,ui-monospace,monospace)}
|
|
556
|
+
.ak-ctl-hex-preview{position:absolute;left:7px;width:16px;height:16px;border-radius:4px;border:1px solid var(--ak-color-border-strong,#8e979d);background:repeating-conic-gradient(var(--ak-color-border,#dde0e2) 0 25%,transparent 0 50%) 50%/8px 8px}
|
|
557
|
+
.ak-ctl-colors{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;border:0;min-inline-size:0}
|
|
558
|
+
.ak-ctl-colors:disabled{opacity:.5}
|
|
559
|
+
.ak-ctl-swatches{display:flex;flex-wrap:wrap;gap:6px}
|
|
560
|
+
.ak-ctl-swatch{width:24px;height:24px;min-width:0;padding:0;border:1px solid var(--ak-color-border-strong,#8e979d);border-radius:var(--ak-radius-sm,6px);cursor:pointer;transition:transform .1s}
|
|
561
|
+
.ak-ctl-swatch:hover:not(:disabled){transform:scale(1.08)}
|
|
562
|
+
.ak-ctl-swatch[aria-pressed="true"]{border:2px solid var(--ak-color-accent,#0f7a5a);box-shadow:inset 0 0 0 2px var(--ak-color-background,#fff)}
|
|
563
|
+
.ak-ctl-swatch-none{background:linear-gradient(135deg,transparent 44%,var(--ak-color-danger,#9f343a) 44%,var(--ak-color-danger,#9f343a) 56%,transparent 56%),var(--ak-color-background,#fff)}
|
|
564
|
+
.ak-ctl-switch{display:inline-flex;align-items:center;gap:10px;min-height:28px;margin:0;padding:0;border:0;background:none;color:var(--ak-color-text,#1d2f3c);font:inherit;font-size:13px;font-weight:600;text-align:left;cursor:pointer;border-radius:var(--ak-radius-sm,6px)}
|
|
565
|
+
.ak-ctl-switch:disabled{opacity:.4;cursor:not-allowed}
|
|
566
|
+
.ak-ctl-switch-track{position:relative;flex-shrink:0;width:32px;height:18px;border-radius:999px;background:var(--ak-color-border-strong,#8e979d);transition:background-color .15s}
|
|
567
|
+
.ak-ctl-switch-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--ak-color-background,#fff);transition:transform .15s}
|
|
568
|
+
.ak-ctl-switch[aria-checked="true"] .ak-ctl-switch-track{background:var(--ak-color-primary-deep,#16a683)}
|
|
569
|
+
.ak-ctl-switch[aria-checked="true"] .ak-ctl-switch-thumb{transform:translateX(14px)}
|
|
570
|
+
.ak-ctl-segmented{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-sm,6px);background:var(--ak-color-surface,#f4f7f7)}
|
|
571
|
+
.ak-ctl-segmented button{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-width:28px;height:26px;padding:0 8px;border:0;border-radius:4px;background:transparent;color:var(--ak-color-muted,#56636d);font:inherit;font-size:12px;font-weight:600;cursor:pointer}
|
|
572
|
+
.ak-ctl-segmented button svg{width:14px;height:14px}
|
|
573
|
+
.ak-ctl-segmented button:hover:not(:disabled){color:var(--ak-color-text,#1d2f3c)}
|
|
574
|
+
.ak-ctl-segmented button[aria-checked="true"]{background:var(--ak-color-background,#fff);color:var(--ak-color-accent,#0f7a5a);box-shadow:0 0 0 1px var(--ak-color-border,#dde0e2)}
|
|
575
|
+
.ak-ctl-inspector{display:flex;flex-direction:column;min-width:0;max-height:80vh;overflow-y:auto;background:var(--ak-color-background,#fff);color:var(--ak-color-text,#1d2f3c);border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-md,8px);font-size:13px}
|
|
576
|
+
.ak-ctl-section{display:flex;flex-direction:column;gap:12px;margin:0;padding:14px;border:0;border-top:1px solid var(--ak-color-border,#dde0e2)}
|
|
577
|
+
.ak-ctl-section:first-child{border-top:0}
|
|
578
|
+
.ak-ctl-section-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:20px}
|
|
579
|
+
.ak-ctl-section-title{margin:0;font-family:var(--ak-font-heading,"Sora",system-ui,sans-serif);font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ak-color-text,#1d2f3c)}
|
|
580
|
+
.ak-ctl-section-actions{display:flex;gap:4px}
|
|
581
|
+
.ak-ctl-empty{margin:0;font-size:13px;line-height:1.55;color:var(--ak-color-muted,#56636d)}
|
|
582
|
+
.ak-ctl-card{display:flex;flex-direction:column;gap:8px;margin:0;padding:10px;border:1px solid var(--ak-color-border,#dde0e2);border-radius:var(--ak-radius-md,8px);background:var(--ak-color-surface,#f4f7f7);min-inline-size:0}
|
|
583
|
+
.ak-ctl-list{display:flex;flex-direction:column;gap:2px;margin:0;padding:0;list-style:none}
|
|
584
|
+
.ak-ctl-list-item{display:flex;align-items:center;gap:8px;width:100%;min-height:32px;padding:0 10px;border:1px solid transparent;border-radius:var(--ak-radius-sm,6px);background:none;color:var(--ak-color-text,#1d2f3c);font:inherit;font-size:13px;text-align:left;cursor:pointer}
|
|
585
|
+
.ak-ctl-list-item:hover:not(:disabled){background:var(--ak-color-hover,#f2f3f4)}
|
|
586
|
+
.ak-ctl-list-item[aria-current="true"]{background:var(--ak-color-fill-accent,#e7f2ef);border-color:var(--ak-color-accent,#0f7a5a);color:var(--ak-color-accent,#0f7a5a)}
|
|
587
|
+
`;
|
package/src/download.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ClientExportResult } from './client.js';
|
|
2
|
+
|
|
3
|
+
/** Saves exported bytes through a temporary object URL. No-op outside the browser. */
|
|
4
|
+
export function triggerDownload(file: Pick<ClientExportResult, 'bytes' | 'mimeType' | 'filename'>): void {
|
|
5
|
+
if (
|
|
6
|
+
typeof document === 'undefined' ||
|
|
7
|
+
typeof URL === 'undefined' ||
|
|
8
|
+
typeof URL.createObjectURL !== 'function'
|
|
9
|
+
)
|
|
10
|
+
return;
|
|
11
|
+
const blob = new Blob([file.bytes.slice()], { type: file.mimeType });
|
|
12
|
+
const url = URL.createObjectURL(blob);
|
|
13
|
+
const anchor = document.createElement('a');
|
|
14
|
+
anchor.href = url;
|
|
15
|
+
anchor.download = file.filename;
|
|
16
|
+
anchor.rel = 'noopener';
|
|
17
|
+
anchor.style.display = 'none';
|
|
18
|
+
document.body.appendChild(anchor);
|
|
19
|
+
anchor.click();
|
|
20
|
+
anchor.remove();
|
|
21
|
+
// Revoking synchronously can cancel the download in some browsers.
|
|
22
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
23
|
+
}
|