figma-plugin-utilities 0.2.0 → 0.3.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 ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
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).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-05-06
11
+
12
+ WCAG 2.2 AA accessibility audit and remediation across all components.
13
+
14
+ ### Added
15
+ - `class` prop passthrough to **ListItem**, **LoadingState**, and **StatusBar** — consistent with other components
16
+ - `role="alert"` for error/warning and `role="status"` for info/success on **StatusBar** — messages are now announced by screen readers on insertion
17
+ - `"AAA-large"` case (4.5:1) to `meetsContrastLevel` in `lib/colors.js` — covers WCAG 1.4.6 large text at AAA level
18
+ - GitHub Actions publish workflow (`.github/workflows/publish.yml`) — triggers `npm publish` on GitHub release creation
19
+ - `aria-pressed={active}` to **ListItem** — communicates selection state to assistive technology
20
+ - Space key activation to **ListItem** — keyboard users can now toggle items with Space as well as Enter
21
+ - `ariaLabel="{title} options"` to the **ListItem** menu `IconButton` — gives the icon-only button an accessible name
22
+ - `role="status"` to **LoadingState** — loading message is announced as a polite live region
23
+ - `aria-hidden="true"` to the decorative icon in **EmptyState** — prevents redundant AT announcement
24
+ - `aria-disabled` attribute to **CheckboxCard** — reflects disabled state without removing from the accessibility tree
25
+ - Dev-mode `console.warn` to **FieldGroup** when `label` is provided but `labelFor` is empty
26
+
27
+ ### Changed
28
+ - **Header**: outer element changed from `<div>` to `<header>`; title changed from `<h2>` to `<h1>` (plugin UI runs in its own iframe, so the heading hierarchy starts fresh)
29
+ - **Footer**: outer element changed from `<div>` to `<footer>`
30
+ - **CheckboxCard**: removed `role="button"` and `tabindex` from the wrapper div — the native checkbox input is the sole interactive/focusable element; the wrapper remains clickable for mouse users via a delegating click handler
31
+ - **CheckboxCard**: added `user-select: none` to prevent text selection on double-click
32
+
33
+ ## [0.1.0] - 2026-04-17
34
+
35
+ Initial release as `figma-plugin-utilities`.
36
+
37
+ ### Added
38
+ - **PluginLayout** — main content wrapper with scrollable area
39
+ - **Header** — header bar with left/center/right slots and optional title
40
+ - **Footer** — footer bar with right, split, and full layout variants
41
+ - **StatusBar** — toast-style notifications with auto-dismiss for info/success types
42
+ - **EmptyState** — empty/error state display with optional icon and action buttons
43
+ - **ListItem** — selectable list item with metadata slot and context menu
44
+ - **LoadingState** — centered loading indicator
45
+ - **FieldGroup** — label + input wrapper for form fields
46
+ - **CheckboxCard** — large-target checkbox with card styling
47
+ - `lib/messages.js` — `sendToPlugin` and `createMessageHandler`
48
+ - `lib/colors.js` — `rgbToHex`, `hexToRgb`, `getLuminance`, `getContrastRatio`, `meetsContrastLevel`
49
+ - `lib/validation.js` — `validateUrl`, `validateJsonString`, `validateEmail`, `validateNumber`, `sanitizeName`, `sanitizeInput`, `isEmpty`
50
+ - `lib/errorHandling.js` — `safeAsync`, `parseJsonSafe`, `notifyError`, `notifySuccess`, `notifyWarning`, and more
51
+ - `lib/resize.js` — `resizeToFit`, `autoResize`, `setDefaultWidth`, `getContentHeight`
52
+ - `lib/figma-helpers.ts` — `sendToUI`, `showError`, `showSuccess`, `getCollections`, `getVariables`, `getSelection`, `focusNodes`, `loadFont`, `saveToStorage`, `loadFromStorage`, `handleResize`
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marius Roosendaal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Shared Svelte components and utilities for Figma plugins.
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install figma-plugin-ulities
8
+ npm install figma-plugin-utilities
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -24,25 +24,45 @@ import {
24
24
  LoadingState,
25
25
  FieldGroup,
26
26
  CheckboxCard,
27
- // Utilities
27
+ // Messages
28
28
  sendToPlugin,
29
29
  createMessageHandler,
30
+ // Colors
30
31
  rgbToHex,
31
32
  hexToRgb,
33
+ getLuminance,
34
+ getContrastRatio,
35
+ meetsContrastLevel,
36
+ // Validation
32
37
  validateUrl,
33
38
  validateJsonString,
39
+ validateEmail,
40
+ validateNumber,
34
41
  sanitizeName,
35
- } from "figma-plugin-utils";
42
+ sanitizeInput,
43
+ isEmpty,
44
+ // Error handling
45
+ safeAsync,
46
+ parseJsonSafe,
47
+ notifyError,
48
+ notifySuccess,
49
+ notifyWarning,
50
+ // Resize
51
+ setDefaultWidth,
52
+ getContentHeight,
53
+ resizeToFit,
54
+ autoResize,
55
+ } from "figma-plugin-utilities";
36
56
  ```
37
57
 
38
58
  ### Import Specific Modules
39
59
 
40
60
  ```javascript
41
61
  // Components only
42
- import { PluginLayout, Header, Footer } from "figma-plugin-utils/components";
62
+ import { PluginLayout, Header, Footer } from "figma-plugin-utilities/components";
43
63
 
44
64
  // Utilities only
45
- import { sendToPlugin, createMessageHandler } from "figma-plugin-utils/lib";
65
+ import { sendToPlugin, createMessageHandler } from "figma-plugin-utilities/lib";
46
66
  ```
47
67
 
48
68
  ## Components
@@ -144,7 +164,7 @@ Types: `info`, `success`, `error`, `warning`. Auto-dismisses after 4s for `info`
144
164
 
145
165
  ### CheckboxCard
146
166
 
147
- Large checkbox with card-style background and better touch targets.
167
+ Large checkbox with card-style background and better touch targets.
148
168
 
149
169
  ```svelte
150
170
  <!-- Basic usage -->
@@ -195,7 +215,8 @@ window.onmessage = createMessageHandler({
195
215
  const rgb = hexToRgb("#FF0000"); // { r: 1, g: 0, b: 0 }
196
216
  const hex = rgbToHex({ r: 1, g: 0, b: 0 }); // "#FF0000"
197
217
 
198
- // Calculate contrast
218
+ // Contrast utilities
219
+ const luminance = getLuminance({ r: 1, g: 0, b: 0 });
199
220
  const ratio = getContrastRatio(color1, color2);
200
221
  const passes = meetsContrastLevel(ratio, "AA"); // true/false
201
222
  ```
@@ -209,7 +230,12 @@ const urlResult = validateUrl("https://example.com");
209
230
  const jsonResult = validateJsonString('{"key": "value"}');
210
231
  // { valid: true, parsed: {...} } or { valid: false, error: "..." }
211
232
 
233
+ validateEmail("user@example.com"); // { valid: true }
234
+ validateNumber("42", { min: 0, max: 100 }); // { valid: true, value: 42 }
235
+
212
236
  const clean = sanitizeName("My Plugin!!!"); // "My Plugin"
237
+ sanitizeInput("<script>alert(1)</script>"); // escaped string
238
+ isEmpty(""); // true
213
239
  ```
214
240
 
215
241
  ### Error Handling (`lib/errorHandling.js`)
@@ -229,20 +255,71 @@ if (result.ok) {
229
255
  // Parse JSON safely
230
256
  const parsed = parseJsonSafe(jsonString);
231
257
  // { ok: true, value: {...} } or { ok: false, error: "..." }
258
+
259
+ // Figma notifications
260
+ notifySuccess("Done!");
261
+ notifyError("Something went wrong");
262
+ notifyWarning("Check your input");
232
263
  ```
233
264
 
265
+ ### Resize (`lib/resize.js`)
266
+
267
+ Utilities for dynamically resizing the plugin window to fit its content.
268
+
269
+ ```javascript
270
+ // One-time resize to fit content
271
+ resizeToFit({ width: 300, minHeight: 100, maxHeight: 600 });
272
+
273
+ // Watch for content changes and auto-resize
274
+ const cleanup = autoResize({
275
+ container: myContainerEl, // bind:this on a naturally-flowing wrapper
276
+ width: 300,
277
+ minHeight: 100,
278
+ maxHeight: 600,
279
+ });
280
+
281
+ // Call cleanup when the component is destroyed
282
+ onDestroy(cleanup);
283
+
284
+ // Set default width used across all resize calls
285
+ setDefaultWidth(320);
286
+ ```
287
+
288
+ > **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
+
234
290
  ### Figma Helpers (`lib/figma-helpers.ts`)
235
291
 
236
292
  For use in `code.ts`:
237
293
 
238
294
  ```typescript
239
- import { sendToUI, showError, focusNodes, loadFont } from "figma-plugin-utils/lib/figma-helpers";
295
+ import {
296
+ sendToUI,
297
+ showError,
298
+ showSuccess,
299
+ getCollections,
300
+ getVariables,
301
+ getSelection,
302
+ focusNodes,
303
+ loadFont,
304
+ saveToStorage,
305
+ loadFromStorage,
306
+ handleResize,
307
+ } from "figma-plugin-utilities/lib/figma-helpers";
240
308
 
241
309
  // Send message to UI
242
310
  sendToUI("success", { message: "Done!" });
243
311
 
244
- // Show notification
312
+ // Show notifications
245
313
  showError("Something went wrong");
314
+ showSuccess("Created!");
315
+
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
246
323
 
247
324
  // Focus viewport on nodes
248
325
  focusNodes(figma.currentPage.selection);
@@ -253,4 +330,7 @@ await loadFont("Inter", "Regular");
253
330
  // Client storage
254
331
  await saveToStorage("settings", { theme: "dark" });
255
332
  const settings = await loadFromStorage("settings", { theme: "light" });
333
+
334
+ // Handle resize message from UI (call in your message handler)
335
+ if (msg.type === "resize") handleResize(msg);
256
336
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-plugin-utilities",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared Svelte components and utilities for Figma plugins",
5
5
  "type": "module",
6
6
  "svelte": "./src/index.js",
@@ -30,7 +30,12 @@
30
30
  "default": "./src/lib/figma-helpers.ts"
31
31
  }
32
32
  },
33
- "files": ["src"],
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE",
37
+ "CHANGELOG.md"
38
+ ],
34
39
  "repository": {
35
40
  "type": "git",
36
41
  "url": "git+https://github.com/mariusroosendaal/figma-plugin-utilities.git"
@@ -50,7 +55,7 @@
50
55
  "author": "Marius Roosendaal",
51
56
  "license": "MIT",
52
57
  "dependencies": {
53
- "figma-ui3-kit-svelte": "file:../figma-ui3-kit-svelte",
58
+ "figma-ui3-kit-svelte": "^0.5.0",
54
59
  "svelte": "^4.2.20"
55
60
  }
56
61
  }
@@ -32,34 +32,32 @@
32
32
  /** Whether checkbox is disabled */
33
33
  export let disabled = false;
34
34
 
35
- function handleCardClick(e) {
35
+ let cardEl;
36
+
37
+ function handleChange(e) {
36
38
  if (disabled) return;
37
-
38
- // Don't toggle if clicking directly on the checkbox or label
39
- // (let the checkbox handle it natively)
40
- const target = e.target;
41
- const isCheckboxOrLabel =
42
- target.tagName === "INPUT" ||
43
- target.tagName === "LABEL" ||
44
- target.closest("label");
45
-
46
- if (isCheckboxOrLabel) return;
47
-
48
- // Toggle and dispatch change event
49
- checked = !checked;
39
+ checked = e.target.checked;
50
40
  dispatch("change", { checked });
51
41
  }
42
+
43
+ function handleCardClick(e) {
44
+ if (disabled) return;
45
+ // Clicks inside the Checkbox component (label/input) are handled natively
46
+ if (e.target.closest(".checkbox-container")) return;
47
+ cardEl?.querySelector('input[type="checkbox"]')?.click();
48
+ }
52
49
  </script>
53
50
 
54
- <div
55
- class="checkbox-card"
51
+ <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
52
+ <!-- Keyboard users interact with the native checkbox input inside; this div is a mouse-only larger click target -->
53
+ <div
54
+ class="checkbox-card"
56
55
  class:disabled
56
+ aria-disabled={disabled || undefined}
57
+ bind:this={cardEl}
57
58
  on:click={handleCardClick}
58
- on:keydown={(e) => e.key === "Enter" && handleCardClick(e)}
59
- role="button"
60
- tabindex={disabled ? -1 : 0}
61
59
  >
62
- <Checkbox {checked} {disabled} on:change>
60
+ <Checkbox {checked} {disabled} on:change={handleChange}>
63
61
  <slot />
64
62
  </Checkbox>
65
63
  {#if $$slots.secondary}
@@ -80,6 +78,7 @@
80
78
  border-radius: var(--border-radius-medium);
81
79
  cursor: pointer;
82
80
  min-height: 24px;
81
+ user-select: none;
83
82
  }
84
83
 
85
84
  .checkbox-card:hover {
@@ -43,7 +43,7 @@
43
43
  class:large={size === "large"}
44
44
  >
45
45
  {#if icon}
46
- <div class="empty-state__icon">
46
+ <div class="empty-state__icon" aria-hidden="true">
47
47
  {#if typeof icon === "string"}
48
48
  <Icon iconName={icon} />
49
49
  {:else}
@@ -19,6 +19,10 @@
19
19
 
20
20
  /** Size of the label (optional) */
21
21
  export let size = undefined;
22
+
23
+ $: if (typeof window !== "undefined" && label && !labelFor) {
24
+ console.warn("[FieldGroup] A label is rendered but no labelFor is set. Associate the label with its control using the labelFor prop.");
25
+ }
22
26
  </script>
23
27
 
24
28
  <div class="field-group" class:small={size === "small"}>
@@ -31,7 +31,7 @@
31
31
  export let className = "";
32
32
  </script>
33
33
 
34
- <div class="footer footer--{variant} {className}">
34
+ <footer class="footer footer--{variant} {className}">
35
35
  {#if variant === "right"}
36
36
  <div class="footer__right">
37
37
  <slot />
@@ -46,7 +46,7 @@
46
46
  {:else if variant === "full"}
47
47
  <slot />
48
48
  {/if}
49
- </div>
49
+ </footer>
50
50
 
51
51
  <style>
52
52
  .footer {
@@ -11,7 +11,7 @@
11
11
  export let noBorder = false;
12
12
  </script>
13
13
 
14
- <div
14
+ <header
15
15
  class="header {className}"
16
16
  class:has-left-content={$$slots.left}
17
17
  class:no-border={noBorder}
@@ -19,7 +19,7 @@
19
19
  <div class="header__left">
20
20
  <slot name="left" />
21
21
  {#if title}
22
- <h2 class="header__title">{title}</h2>
22
+ <h1 class="header__title">{title}</h1>
23
23
  {/if}
24
24
  </div>
25
25
  <div class="header__center">
@@ -28,7 +28,7 @@
28
28
  <div class="header__right">
29
29
  <slot name="right" />
30
30
  </div>
31
- </div>
31
+ </header>
32
32
 
33
33
  <style>
34
34
  .header {
@@ -44,6 +44,9 @@
44
44
  /** Whether to show badge slot */
45
45
  export let hasBadge = false;
46
46
 
47
+ let className = "";
48
+ export { className as class };
49
+
47
50
  function handleClick() {
48
51
  dispatch("click", { id });
49
52
  }
@@ -65,14 +68,15 @@
65
68
  }
66
69
  </script>
67
70
 
68
- <div class="list-item-wrapper">
71
+ <div class="list-item-wrapper {className}">
69
72
  <div
70
73
  class="list-item"
71
74
  class:active
72
75
  on:click={handleClick}
73
- on:keydown={(e) => e.key === "Enter" && handleClick()}
76
+ on:keydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); } }}
74
77
  role="button"
75
78
  tabindex="0"
79
+ aria-pressed={active}
76
80
  >
77
81
  <div class="list-item__content">
78
82
  <div class="list-item__title">{title}</div>
@@ -92,6 +96,7 @@
92
96
  {#if menuItems.length > 0}
93
97
  <IconButton
94
98
  iconName={IconMore}
99
+ ariaLabel="{title} options"
95
100
  bind:element={menuButtonElement}
96
101
  on:click={handleMenuToggle}
97
102
  />
@@ -15,9 +15,12 @@
15
15
 
16
16
  /** Message to display */
17
17
  export let message = "Loading...";
18
+
19
+ let className = "";
20
+ export { className as class };
18
21
  </script>
19
22
 
20
- <div class="loading-state">
23
+ <div class="loading-state {className}" role="status">
21
24
  <Text variant="body-medium" color="--figma-color-text-secondary">{message}</Text>
22
25
  </div>
23
26
 
@@ -6,9 +6,7 @@
6
6
  * @example
7
7
  * <PluginLayout>
8
8
  * <p>Main content here</p>
9
- * <svelte:fragment slot="footer">
10
- * <Button variant="primary">Create item</Button>
11
- * </svelte:fragment>
9
+ * <Button variant="primary">Create item</Button>
12
10
  * </PluginLayout>
13
11
  */
14
12
 
@@ -40,4 +38,8 @@
40
38
  overflow-y: auto;
41
39
  padding: var(--size-xsmall);
42
40
  }
41
+
42
+ .plugin-footer {
43
+ flex-shrink: 0;
44
+ }
43
45
  </style>
@@ -14,6 +14,9 @@
14
14
  /** Status type: 'info', 'success', 'error', 'warning' */
15
15
  export let type = "info";
16
16
 
17
+ let className = "";
18
+ export { className as class };
19
+
17
20
  let visible = false;
18
21
  let timeoutId;
19
22
 
@@ -53,14 +56,16 @@
53
56
 
54
57
  {#if visible && message}
55
58
  <div
56
- class="status-bar"
59
+ class="status-bar {className}"
57
60
  class:status-bar--error={type === "error"}
58
61
  class:status-bar--success={type === "success"}
59
62
  class:status-bar--warning={type === "warning"}
63
+ role={type === "error" || type === "warning" ? "alert" : "status"}
60
64
  >
61
65
  <span>{message}</span>
62
66
  <IconButton
63
67
  iconName={IconClose}
68
+ ariaLabel="Dismiss"
64
69
  on:click={handleClose}
65
70
  iconColor={computedIconColor}
66
71
  />
package/src/index.js CHANGED
@@ -38,6 +38,9 @@ export {
38
38
  withErrorHandling,
39
39
  safeAsync,
40
40
  parseJsonSafe,
41
+ notifyError,
42
+ notifySuccess,
43
+ notifyWarning,
41
44
  // Resize
42
45
  setDefaultWidth,
43
46
  getContentHeight,
package/src/lib/colors.js CHANGED
@@ -60,13 +60,15 @@ export function getContrastRatio(color1, color2) {
60
60
  /**
61
61
  * Check if contrast ratio meets WCAG level
62
62
  * @param {number} ratio - Contrast ratio
63
- * @param {"AA" | "AAA" | "AA-large"} level - WCAG level to check
63
+ * @param {"AA" | "AAA" | "AA-large" | "AAA-large"} level - WCAG level to check
64
64
  * @returns {boolean} Whether the ratio meets the level
65
65
  */
66
66
  export function meetsContrastLevel(ratio, level) {
67
67
  switch (level) {
68
68
  case "AAA":
69
69
  return ratio >= 7;
70
+ case "AAA-large":
71
+ return ratio >= 4.5;
70
72
  case "AA":
71
73
  return ratio >= 4.5;
72
74
  case "AA-large":
package/src/lib/resize.js CHANGED
@@ -2,12 +2,12 @@
2
2
  * Auto-resize utilities for Figma plugin windows
3
3
  *
4
4
  * Usage in UI:
5
- * import { resizeToFit, autoResize } from "figma-plugin-utils";
5
+ * import { resizeToFit, autoResize } from "figma-plugin-utilities";
6
6
  * resizeToFit(); // One-time resize
7
7
  * autoResize(); // Watch for changes and auto-resize
8
8
  *
9
9
  * Usage in code.ts:
10
- * import { handleResize } from "figma-plugin-utils/lib/figma-helpers";
10
+ * import { handleResize } from "figma-plugin-utilities/lib/figma-helpers";
11
11
  * // In your message handler:
12
12
  * if (msg.type === "resize") handleResize(msg);
13
13
  */