figma-plugin-utilities 0.3.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,11 +1,64 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## [Unreleased]
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ## [0.5.0] - 2026-10-07
7
6
 
8
- ## [Unreleased]
7
+ ### Added
8
+ - **ConfirmModal** with `confirmAction`, from Data Mapper — a confirmation in a small modal in place of the browser's `confirm()`, resolving true or false, its buttons stacked at full width in a window too narrow for them side by side — and `confirmDiscardChanges`, the "Discard changes?" prompt before unsaved edits go, for Modal's `beforeClose`
9
+ - **MappingChip** — one side of a source → target row as a filled 24px chip: a button with a lead icon or chit, a truncating `label`, an optional `preview` and `count`. `tone` is `default`, `secondary` or `component`; `selected` draws the selection border
10
+ - **FieldGrid** — fields side by side in `columns` equal columns (2 by default) that shrink below their content
11
+ - **SteppedField** — a field with − and + icon buttons after it, named by `downLabel` and `upLabel`, firing `step` with -1 or 1
12
+ - **LadderBadges** — a scale's sizes as badges, outlined where used and `archived` where not, each with a tooltip
13
+ - **RampCurve** — a ramp's quadratic Bézier at one breakpoint, the others faint behind it. At the smallest and largest breakpoint the ends and the bend are draggable or stepped with the arrow keys. Fires `change` and `select`
14
+ - **CodeExportModal** — read-only code in a modal with a copy button that reads "Copied" for two seconds, and a `controls` slot for options above the code
15
+ - **Section** — a titled group of fields as in Figma's panels, with icon buttons in an `actions` slot. It pads its own content and shrinks with its container
16
+ - **DataTable** — named rows with a cell per column. Selectable rows open an `editor` slot; with `selectable={false}` it's a read-only table. Rows take badges with tooltips, and cells are badges, variable chips or text, colored as new, changed or danger
17
+ - `lib/scale` — responsive scale math with no Figma API: the ramp's Bézier (`bezier`, `clampPosition`, `levelT`), `blendAt`, `valueAtWidth`, fallback breakpoint widths (`FALLBACK_WIDTHS`, `isBreakpointName`, `widthsFor`), `fluidClamp`, `trimNumber`, `lerp` and `roundHalfDown`
18
+ - `lib/figma-variables` — `isVariableAlias`, `isColorValue`, `toRgba`, `getVariableLookup`, and `resolveVariableValue` and `resolveVariableValueAsync`, which follow aliases at a mode; the async one follows library variables too
19
+ - **figma-helpers** — `fontsOf`, `loadFontOnce`, `loadNodeFonts` and `setText` write text in the layer's own fonts, loading each font once per run; `createSettingsStore` keeps settings in clientStorage, cleaned by one `sanitize` on load and on save
20
+ - `isValidHex` — six HEX digits in either case, the "#" required unless `requireHash` is false
21
+ - `plural` and `joinList` — "3 layers", "a, b and c"; from the root, or `lib/format` in code.ts
22
+ - **figma-helpers** — `showNotice`, a regular notification for a run with nothing to do, where `showError` is for failures
23
+ - `UNDO` — "Press Ctrl/Cmd+Z to undo.", the last sentence of a success notification for a change to the file
24
+
25
+ ### Changed
26
+ - **ListItem** — an `actions` slot puts buttons inside the item, after its text and outside its clickable area, in the Figma component too (**Actions slot**)
27
+ - **Header** — `level` sets the title's heading level (1 by default). The left padding is 8px when the left slot has content and 16px before a title alone, in the Figma component too
28
+ - **Footer** — a kit `Text` at the edge of a split footer sits 16px in, where buttons sit 8px in
29
+ - `rgbToHex` takes `lowercase`, `hash` and `alpha` options and clamps channels to 0–1
30
+ - The color utilities are TypeScript, with their own `lib/colors` entry for code.ts
31
+ - `loadFont` loads each font once per run
32
+ - `showError` and `showSuccess` stay up long enough to read by default, about 60ms a character, and at least 5s and 3s
33
+ - **figma-frame-builders** — `createTokenChip` pads its label 6px on each side, and `specTokens.accentColors.green` is #40C459, as in the Vitrine spec library
34
+
35
+ ### Removed
36
+ - `getCollections`, `getVariables` and `getSelection` — call the Figma API directly
37
+ - `saveToStorage` and `loadFromStorage` — use `createSettingsStore`
38
+ - `notifyError`, `notifySuccess` and `notifyWarning`, which did nothing in the UI — use `showError` and `showSuccess` in code.ts
39
+
40
+ ## [0.4.0] - 2026-09-21
41
+
42
+ ### Added
43
+ - `figma-frame-builders.ts` — new module with Figma frame and component builder utilities, imported from `figma-plugin-utilities/lib/figma-frame-builders` (its own export entry). They mirror the Vitrine spec library — colors, typography, spacing and layer names (`label` chip and text, a `tokens` row in token cells, `title` in both header variants):
44
+ - `createAutoLayoutFrame` — creates a `FrameNode` with auto-layout configured
45
+ - `createAutoLayoutComponent` — creates a `ComponentNode` with auto-layout configured
46
+ - `createText` — creates a styled `TextNode`
47
+ - `createTokenChip` — creates a rounded chip frame for displaying color tokens
48
+ - `createColorSwatch` — creates a color swatch frame
49
+ - `createTableCell` — creates a table cell frame
50
+ - `createTableHeader` — creates a table header frame
51
+ - `loadSpecFonts` — loads Inter and IBM Plex Mono font faces in parallel
52
+ - `specTokens` — design token constants (accent colors, font specs, light/dark themes with an optional `headerBorder`)
53
+ - `PaddingSpec`, `SpecTheme`, `NodeKind` and `NodeFor` types
54
+
55
+ ### Removed
56
+ - The dev-mode `console.warn` **FieldGroup** logged when `label` was set without `labelFor` — a `Dropdown` is a button and cannot be a `<label for>` target, so it fired on correct code. Dropped in the a11y pass, recorded late
57
+
58
+ ### Fixed
59
+ - **StatusBar** — the default `info` type sets `color: var(--figma-color-text)`. The `error`, `success` and `warning` types each set a foreground; the default one relied on inheritance, and nothing up the tree sets `color`, so the message rendered in the UA's black on the dark theme's grey bar
60
+ - **EmptyState** — the actions are a keyed `{#each}`, so swapping one action for another reuses the right button rather than repainting the row
61
+ - **docs** — `figma-frame-builders` is documented, `sanitizeInput` no longer claims to escape HTML (it stringifies, truncates, strips control characters and trims), and `formatErrorMessage`, `handleAsyncError`, `withErrorHandling` and `logError` are documented with their real signatures. `withErrorHandling(fn, operation)` calls `fn()` with no arguments and returns its result; it was documented as returning a wrapped function
9
62
 
10
63
  ## [0.3.1] - 2026-05-13
11
64
 
package/README.md CHANGED
@@ -24,6 +24,14 @@ import {
24
24
  LoadingState,
25
25
  FieldGroup,
26
26
  CheckboxCard,
27
+ Section,
28
+ DataTable,
29
+ FieldGrid,
30
+ SteppedField,
31
+ LadderBadges,
32
+ CodeExportModal,
33
+ RampCurve,
34
+ MappingChip,
27
35
  // Messages
28
36
  sendToPlugin,
29
37
  createMessageHandler,
@@ -44,9 +52,6 @@ import {
44
52
  // Error handling
45
53
  safeAsync,
46
54
  parseJsonSafe,
47
- notifyError,
48
- notifySuccess,
49
- notifyWarning,
50
55
  // Resize
51
56
  setDefaultWidth,
52
57
  getContentHeight,
@@ -73,11 +78,21 @@ import { sendToPlugin, createMessageHandler } from "figma-plugin-utilities/lib";
73
78
  | `Header` | Header bar with `left`, `center`, `right` slots and optional title |
74
79
  | `Footer` | Footer with `right`, `split`, and `full` layout variants |
75
80
  | `StatusBar` | Toast notifications with auto-dismiss (info/success/error/warning) |
76
- | `EmptyState` | Empty/error states with optional icon and action buttons |
77
- | `ListItem` | Selectable list items with metadata slot and action menu |
78
- | `LoadingState` | Centered loading indicator with custom message |
79
- | `FieldGroup` | Label + input wrapper for form fields |
80
- | `CheckboxCard` | Large checkbox with card styling and better touch targets |
81
+ | `EmptyState` | Empty/error states with optional icon and action buttons; `size`, `centered`, and `role="alert"` for failures |
82
+ | `ListItem` | Selectable list items with metadata and `badge` slots, an action menu (`menuOpen`, `menuToggle`, `menuClose`) |
83
+ | `LoadingState` | Centred message as `role="status"` (text only, no spinner) |
84
+ | `FieldGroup` | Label + input wrapper; `labelFor` binds the label to a text control |
85
+ | `CheckboxCard` | Large checkbox with card styling and better touch targets; `change` event |
86
+ | `Section` | Titled group of fields as in Figma's panels: a `Header` with the title and an `actions` slot, content padded by the section itself |
87
+ | `DataTable` | Named rows with a cell per column (a set at each breakpoint, a style before and after): columns with their own width and alignment, a read-only mode with table roles, row selection with an `editor` slot, notes as badges, with tooltips, and a `+N` count past two, an `action` slot, values as badges or variable chips, removed rows and a marked column |
88
+ | `FieldGrid` | Fields side by side in equal columns (`columns`, default 2) that shrink below their content |
89
+ | `SteppedField` | A field with − and + icon buttons after it, as one grid cell; `step` event with -1 or 1 |
90
+ | `LadderBadges` | A scale's sizes as badges, outlined where used and archived where not, each with the caller's tooltip |
91
+ | `RampCurve` | A ramp's Bézier at the breakpoint shown, in the caller's units: handles for the ends and the bend at the smallest and largest breakpoint, blends between; `change` and `select` events |
92
+ | `MappingChip` | One side of a source → target row: a filled 24px chip with a lead icon or chit, a truncating label, a `preview` after a dot and a trailing `count`; `click` event |
93
+ | `CodeExportModal` | Read-only code in a modal with a copy button that reads "Copied" for 2s; a `controls` slot above the code |
94
+
95
+ Every component also takes a `class` (or `className`) prop.
81
96
 
82
97
  ### Header
83
98
 
@@ -193,6 +208,103 @@ Large checkbox with card-style background and better touch targets.
193
208
  </CheckboxCard>
194
209
  ```
195
210
 
211
+ ### FieldGrid
212
+
213
+ ```svelte
214
+ <FieldGrid columns={3}>
215
+ <FieldGroup label="Base">…</FieldGroup>
216
+ <FieldGroup label="Ratio">…</FieldGroup>
217
+ <FieldGroup label="Steps">…</FieldGroup>
218
+ </FieldGrid>
219
+ ```
220
+
221
+ ### SteppedField
222
+
223
+ ```svelte
224
+ <SteppedField
225
+ downLabel="Step {set.name} down"
226
+ upLabel="Step {set.name} up"
227
+ on:step={(e) => setOffset(set.offset + e.detail)}
228
+ >
229
+ <FieldGroup label="Steps off the curve" labelFor="offset" size="small">
230
+ <NumericInput id="offset" value={set.offset} precision={0} />
231
+ </FieldGroup>
232
+ </SteppedField>
233
+ ```
234
+
235
+ ### LadderBadges
236
+
237
+ ```svelte
238
+ <LadderBadges
239
+ ariaLabel="Ladder sizes"
240
+ badges={ladder.map((value, i) => ({
241
+ value,
242
+ used: used.has(i),
243
+ title: used.has(i) ? "Used by a style" : "Unused",
244
+ }))}
245
+ />
246
+ ```
247
+
248
+ Each badge's accessible name is `"{value}px, used"` or `"{value}px, unused"`.
249
+
250
+ ### MappingChip
251
+
252
+ ```svelte
253
+ <MappingChip
254
+ label={row.sourceName}
255
+ count={row.uses}
256
+ tone="secondary"
257
+ title="Select these icons"
258
+ on:click={() => reveal(row)}
259
+ />
260
+ ```
261
+
262
+ `iconName` or `chit` (which wins) adds a lead that hangs into the padding; `preview` follows the label as "label · preview"; `tone` is `default`, `secondary` or `component`; `selected` draws the selection border. The `lead` slot takes a marker ahead of the lead, and `element` binds the button.
263
+
264
+ ### RampCurve
265
+
266
+ ```svelte
267
+ <RampCurve
268
+ {ramp}
269
+ curves={breakpoints.map((_, b) => rampAt(ramp, b))}
270
+ span={[lo, hi]}
271
+ grid={ladder.map((size, rung) => ({ key: rung, y: size, label: size }))}
272
+ dots={levels.map((size, k) => ({ key: k, x: k / (levels.length - 1), y: size }))}
273
+ {breakpoints}
274
+ {selected}
275
+ rungCount={ladder.length}
276
+ rungAt={(px) => nearestRung(ladder, px)}
277
+ format={(px) => `${Math.round(px)}px`}
278
+ ariaLabel="Heading ramp"
279
+ bottomLabel="Smallest level"
280
+ topLabel="Largest level"
281
+ along="levels"
282
+ on:change={(e) => (ramp = { ...ramp, ...e.detail })}
283
+ on:select={(e) => (selected = e.detail)}
284
+ />
285
+ ```
286
+
287
+ `ramp` holds the ends as rungs and the bends at each end (`bottomSm`, `topSm`, `bendSm`, `bottomLg`, `topLg`, `bendLg`) and `bendPosition`; `change` patches those keys. Everything else on the y axis is in the caller's units.
288
+
289
+ ### CodeExportModal
290
+
291
+ ```svelte
292
+ <CodeExportModal
293
+ isOpen={exportOpen}
294
+ title="Export CSS"
295
+ value={css}
296
+ ariaLabel="Exported CSS"
297
+ copyLabel="Copy CSS"
298
+ onClose={() => (exportOpen = false)}
299
+ >
300
+ <svelte:fragment slot="controls">
301
+ <SegmentedControl …/>
302
+ </svelte:fragment>
303
+ </CodeExportModal>
304
+ ```
305
+
306
+ Copies with `execCommand`, since the plugin iframe isn't granted clipboard-write. `position` (default `"bottom"`), `width` (`"medium"`) and `height` (`"auto"`) pass through to `Modal`.
307
+
196
308
  ## Utilities
197
309
 
198
310
  ### Messages (`lib/messages.js`)
@@ -231,11 +343,14 @@ const jsonResult = validateJsonString('{"key": "value"}');
231
343
  // { valid: true, parsed: {...} } or { valid: false, error: "..." }
232
344
 
233
345
  validateEmail("user@example.com"); // { valid: true }
234
- validateNumber("42", { min: 0, max: 100 }); // { valid: true, value: 42 }
235
-
236
- const clean = sanitizeName("My Plugin!!!"); // "My Plugin"
237
- sanitizeInput("<script>alert(1)</script>"); // escaped string
238
- isEmpty(""); // true
346
+ validateNumber("42", { min: 0, max: 100, integer: true }); // { valid: true, value: 42 }
347
+ validateUrl("", { required: false }); // { valid: true } — empty is allowed
348
+ validateJsonString(text, { maxSizeKB: 512, requireObject: true });
349
+
350
+ const clean = sanitizeName("My Plugin!!!", 200); // "My Plugin" — "Untitled" if nothing survives
351
+ sanitizeInput(input, 50); // stringify, truncate to maxLength, strip control characters, trim
352
+ // Note: sanitizeInput does NOT escape HTML. Escape at the point of rendering instead.
353
+ isEmpty(""); // true — also for [] and {}
239
354
  ```
240
355
 
241
356
  ### Error Handling (`lib/errorHandling.js`)
@@ -255,11 +370,6 @@ if (result.ok) {
255
370
  // Parse JSON safely
256
371
  const parsed = parseJsonSafe(jsonString);
257
372
  // { ok: true, value: {...} } or { ok: false, error: "..." }
258
-
259
- // Figma notifications
260
- notifySuccess("Done!");
261
- notifyError("Something went wrong");
262
- notifyWarning("Check your input");
263
373
  ```
264
374
 
265
375
  ### Resize (`lib/resize.js`)
@@ -287,6 +397,34 @@ setDefaultWidth(320);
287
397
 
288
398
  > **Note:** The `container` element passed to `autoResize` must **not** have `height: 100%` or a fixed height — it should flow naturally with its content so `scrollHeight` can be measured accurately.
289
399
 
400
+ ### Spec Frame Builders (`lib/figma-frame-builders.ts`)
401
+
402
+ Typed builders for canvas frames in a spec or documentation generator — auto-layout frames and components, text, token chips, color swatches, table cells and headers, with light and dark palettes.
403
+
404
+ ```typescript
405
+ import {
406
+ specTokens, loadSpecFonts,
407
+ createAutoLayoutFrame, createAutoLayoutComponent, createText,
408
+ createTokenChip, createColorSwatch, createTableCell, createTableHeader,
409
+ } from "figma-plugin-utilities/lib/figma-frame-builders";
410
+
411
+ await loadSpecFonts(); // once, before drawing
412
+ const theme = specTokens.themes.dark;
413
+
414
+ const row = createAutoLayoutFrame({ name: "row", direction: "HORIZONTAL", spacing: 8, fill: theme.cellFill });
415
+ row.appendChild(createTokenChip({ label: "#FFFFFF", background: theme.chipBg, textColor: theme.text }));
416
+ ```
417
+
418
+ `specTokens` carries `accentColors`, `fonts` and `themes` (`light`, `dark`). Builders that can return either node take `as: "component"` for a `ComponentNode` instead of a `FrameNode`. Exported types: `PaddingSpec`, `SpecTheme`, `NodeKind`, `NodeFor`.
419
+
420
+ ### Scale Math (`lib/scale.ts`)
421
+
422
+ Pure math for scales shaped across breakpoints — the ramp's quadratic Bézier (`bezier`, `clampPosition`, `levelT`), blending by viewport width (`blendAt`), values between breakpoints (`valueAtWidth`), fallback widths (`FALLBACK_WIDTHS`, `isBreakpointName`, `widthsFor`) and fluid CSS (`fluidClamp`, `trimNumber`), plus `lerp` and `roundHalfDown`. No Figma API: both threads may import it, provided the plugin builds each thread separately.
423
+
424
+ ```typescript
425
+ import { widthsFor, blendAt, fluidClamp } from "figma-plugin-utilities/lib/scale";
426
+ ```
427
+
290
428
  ### Figma Helpers (`lib/figma-helpers.ts`)
291
429
 
292
430
  For use in `code.ts`:
@@ -296,13 +434,10 @@ import {
296
434
  sendToUI,
297
435
  showError,
298
436
  showSuccess,
299
- getCollections,
300
- getVariables,
301
- getSelection,
302
437
  focusNodes,
303
438
  loadFont,
304
- saveToStorage,
305
- loadFromStorage,
439
+ setText,
440
+ createSettingsStore,
306
441
  handleResize,
307
442
  } from "figma-plugin-utilities/lib/figma-helpers";
308
443
 
@@ -313,23 +448,21 @@ sendToUI("success", { message: "Done!" });
313
448
  showError("Something went wrong");
314
449
  showSuccess("Created!");
315
450
 
316
- // Variables
317
- const collections = await getCollections();
318
- const colorVars = await getVariables("COLOR");
319
-
320
- // Selection
321
- const selected = getSelection(); // all selected nodes
322
- const frames = getSelection("FRAME"); // filtered by type
323
-
324
451
  // Focus viewport on nodes
325
452
  focusNodes(figma.currentPage.selection);
326
453
 
327
- // Load font before using
454
+ // Load a font before using it (once per run)
328
455
  await loadFont("Inter", "Regular");
329
456
 
330
- // Client storage
331
- await saveToStorage("settings", { theme: "dark" });
332
- const settings = await loadFromStorage("settings", { theme: "light" });
457
+ // Set a text layer's characters in its own fonts
458
+ await setText(textNode, "Hello");
459
+
460
+ // Settings in client storage, cleaned on load and on save
461
+ const store = createSettingsStore("settings", (raw) => ({
462
+ theme: (raw as { theme?: string })?.theme === "dark" ? "dark" : "light",
463
+ }));
464
+ const settings = await store.load();
465
+ await store.save({ ...settings, theme: "dark" });
333
466
 
334
467
  // Handle resize message from UI (call in your message handler)
335
468
  if (msg.type === "resize") handleResize(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-plugin-utilities",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Shared Svelte components and utilities for Figma plugins",
5
5
  "type": "module",
6
6
  "svelte": "./src/index.js",
@@ -28,6 +28,36 @@
28
28
  "types": "./src/lib/figma-helpers.ts",
29
29
  "import": "./src/lib/figma-helpers.ts",
30
30
  "default": "./src/lib/figma-helpers.ts"
31
+ },
32
+ "./lib/figma-frame-builders": {
33
+ "types": "./src/lib/figma-frame-builders.ts",
34
+ "import": "./src/lib/figma-frame-builders.ts",
35
+ "default": "./src/lib/figma-frame-builders.ts"
36
+ },
37
+ "./lib/figma-variables": {
38
+ "types": "./src/lib/figma-variables.ts",
39
+ "import": "./src/lib/figma-variables.ts",
40
+ "default": "./src/lib/figma-variables.ts"
41
+ },
42
+ "./lib/colors": {
43
+ "types": "./src/lib/colors.ts",
44
+ "import": "./src/lib/colors.ts",
45
+ "default": "./src/lib/colors.ts"
46
+ },
47
+ "./lib/scale": {
48
+ "types": "./src/lib/scale.ts",
49
+ "import": "./src/lib/scale.ts",
50
+ "default": "./src/lib/scale.ts"
51
+ },
52
+ "./lib/format": {
53
+ "types": "./src/lib/format.ts",
54
+ "import": "./src/lib/format.ts",
55
+ "default": "./src/lib/format.ts"
56
+ },
57
+ "./lib/confirm": {
58
+ "types": "./src/lib/confirm.ts",
59
+ "import": "./src/lib/confirm.ts",
60
+ "default": "./src/lib/confirm.ts"
31
61
  }
32
62
  },
33
63
  "files": [
@@ -55,20 +85,20 @@
55
85
  "author": "Marius Roosendaal",
56
86
  "license": "MIT",
57
87
  "devDependencies": {
58
- "@eslint/js": "^9.39.1",
59
- "@figma/plugin-typings": "^1.51.0",
88
+ "@eslint/js": "^9.39.2",
89
+ "@figma/plugin-typings": "^1.138.0",
60
90
  "@sveltejs/vite-plugin-svelte": "^3.0.2",
61
91
  "@types/node": "^22.13.4",
62
- "eslint": "^9.39.1",
63
- "eslint-plugin-svelte": "^2.46.0",
64
- "figma-ui3-kit-svelte": "file:..//_packages/figma-ui3-kit-svelte",
65
- "globals": "^16.5.0",
66
- "prettier": "3.7.3",
92
+ "eslint": "^9.39.2",
93
+ "eslint-plugin-svelte": "^3.23.0",
94
+ "figma-ui3-kit-svelte": "^0.6.0",
95
+ "globals": "^17.12.0",
96
+ "prettier": "^3.9.8",
67
97
  "prettier-plugin-svelte": "^3.4.0",
68
98
  "svelte": "^4.2.20",
69
- "svelte-eslint-parser": "^0.39.0",
99
+ "svelte-eslint-parser": "^1.8.1",
70
100
  "typescript": "^5.9.3",
71
- "typescript-eslint": "^8.48.0",
101
+ "typescript-eslint": "^8.70.0",
72
102
  "vite": "^5.2.0"
73
103
  },
74
104
  "scripts": {
@@ -0,0 +1,98 @@
1
+ <!--
2
+ Read-only code in a modal, with a button that copies it. Each modal keeps
3
+ its own "Copied" state, so copying one never marks the other.
4
+ -->
5
+ <script>
6
+ import { onDestroy } from "svelte";
7
+ import { Button, Modal, Textarea } from "figma-ui3-kit-svelte";
8
+
9
+ export let isOpen = false;
10
+ export let title;
11
+ export let value = "";
12
+ /** Names the code for assistive tech, e.g. "Exported token JSON". */
13
+ export let ariaLabel;
14
+ /** The copy button's label, e.g. "Copy JSON". */
15
+ export let copyLabel;
16
+ export let position = "bottom";
17
+ export let width = "medium";
18
+ export let height = "auto";
19
+ export let onClose = null;
20
+
21
+ let copied = false;
22
+ let copyTimer = null;
23
+ onDestroy(() => clearTimeout(copyTimer));
24
+
25
+ // Reopened, the button reads as it would before a copy.
26
+ $: if (isOpen) copied = false;
27
+
28
+ // execCommand, not the Clipboard API: the plugin iframe isn't granted
29
+ // clipboard-write.
30
+ function handleCopy() {
31
+ try {
32
+ const prevActive = document.activeElement;
33
+ const ta = document.createElement("textarea");
34
+ ta.value = value;
35
+ ta.style.position = "fixed";
36
+ ta.style.left = "-999999px";
37
+ ta.style.top = "-999999px";
38
+ document.body.appendChild(ta);
39
+ ta.focus();
40
+ ta.select();
41
+ const ok = document.execCommand("copy");
42
+ ta.remove();
43
+ if (prevActive instanceof HTMLElement) prevActive.focus();
44
+ if (ok) {
45
+ copied = true;
46
+ clearTimeout(copyTimer);
47
+ copyTimer = setTimeout(() => (copied = false), 2000);
48
+ }
49
+ } catch {
50
+ // execCommand unavailable — nothing to do.
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <Modal
56
+ {isOpen}
57
+ {title}
58
+ {position}
59
+ {width}
60
+ {height}
61
+ overlayPadding="0px"
62
+ {onClose}
63
+ >
64
+ <div class="export-content">
65
+ <!-- Options for what's exported, above the code. -->
66
+ <slot name="controls" />
67
+ <Textarea {value} readonly {ariaLabel} variant="code" />
68
+ </div>
69
+ <svelte:fragment slot="footer-right">
70
+ <Button variant="primary" on:click={handleCopy}>
71
+ {copied ? "Copied" : copyLabel}
72
+ </Button>
73
+ </svelte:fragment>
74
+ </Modal>
75
+
76
+ <style>
77
+ .export-content {
78
+ flex: 1;
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: var(--size-xsmall);
82
+ min-height: 0;
83
+ }
84
+
85
+ .export-content :global(.textarea) {
86
+ flex: 1;
87
+ display: flex;
88
+ flex-direction: column;
89
+ min-height: 0;
90
+ }
91
+
92
+ .export-content :global(textarea) {
93
+ flex: 1;
94
+ min-height: 0;
95
+ resize: none;
96
+ white-space: pre;
97
+ }
98
+ </style>
@@ -0,0 +1,66 @@
1
+ <!--
2
+ The dialog confirmAction() opens: mount one in the plugin's UI, after
3
+ everything else, so it shows above any modal that asks.
4
+ -->
5
+ <script>
6
+ import { Modal, Button, Text } from "figma-ui3-kit-svelte";
7
+ import { confirmRequest, answerConfirm } from "../lib/confirm";
8
+ </script>
9
+
10
+ <Modal
11
+ isOpen={!!$confirmRequest}
12
+ title={$confirmRequest?.title ?? ""}
13
+ width="small"
14
+ position="center"
15
+ on:close={() => answerConfirm(false)}
16
+ >
17
+ <Text>{$confirmRequest?.message ?? ""}</Text>
18
+
19
+ <svelte:fragment slot="footer-full">
20
+ <div class="confirm-footer">
21
+ <div class="confirm-actions">
22
+ <Button variant="secondary" on:click={() => answerConfirm(false)}>
23
+ {$confirmRequest?.cancelLabel || "Cancel"}
24
+ </Button>
25
+ <Button
26
+ variant={$confirmRequest?.destructive ? "destructive" : "primary"}
27
+ on:click={() => answerConfirm(true)}
28
+ >
29
+ {$confirmRequest?.confirmLabel ?? ""}
30
+ </Button>
31
+ </div>
32
+ </div>
33
+ </svelte:fragment>
34
+ </Modal>
35
+
36
+ <style>
37
+ /* The kit's footer is 40px tall; this one grows with stacked buttons. */
38
+ :global(.modal-footer:has(.confirm-footer)) {
39
+ height: auto;
40
+ }
41
+
42
+ /* The footer's width, which the buttons are laid out by. */
43
+ .confirm-footer {
44
+ container-type: inline-size;
45
+ }
46
+
47
+ .confirm-actions {
48
+ display: flex;
49
+ justify-content: flex-end;
50
+ gap: var(--size-xxsmall);
51
+ width: 100%;
52
+ }
53
+
54
+ /* In a narrow window, such as a 240px plugin, the buttons don't fit side
55
+ by side: they stack at full width, the confirming one on top. */
56
+ @container (max-width: 215px) {
57
+ .confirm-actions {
58
+ flex-direction: column-reverse;
59
+ align-items: stretch;
60
+ }
61
+
62
+ .confirm-actions > :global(*) {
63
+ width: 100%;
64
+ }
65
+ }
66
+ </style>