figma-plugin-utilities 0.3.1 → 0.4.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 +22 -4
- package/README.md +35 -10
- package/package.json +15 -10
- package/src/components/EmptyState.svelte +1 -1
- package/src/components/StatusBar.svelte +6 -1
- package/src/lib/figma-frame-builders.ts +467 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
## [0.4.0] - 2026-09-21
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
### Added
|
|
8
|
+
- `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 — colours, typography, spacing and layer names (`label` chip and text, a `tokens` row in token cells, `title` in both header variants):
|
|
9
|
+
- `createAutoLayoutFrame` — creates a `FrameNode` with auto-layout configured
|
|
10
|
+
- `createAutoLayoutComponent` — creates a `ComponentNode` with auto-layout configured
|
|
11
|
+
- `createText` — creates a styled `TextNode`
|
|
12
|
+
- `createTokenChip` — creates a rounded chip frame for displaying color tokens
|
|
13
|
+
- `createColorSwatch` — creates a color swatch frame
|
|
14
|
+
- `createTableCell` — creates a table cell frame
|
|
15
|
+
- `createTableHeader` — creates a table header frame
|
|
16
|
+
- `loadSpecFonts` — loads Inter and IBM Plex Mono font faces in parallel
|
|
17
|
+
- `specTokens` — design token constants (accent colors, font specs, light/dark themes with an optional `headerBorder`)
|
|
18
|
+
- `PaddingSpec`, `SpecTheme`, `NodeKind` and `NodeFor` types
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
- 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
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **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
|
|
25
|
+
- **EmptyState** — the actions are a keyed `{#each}`, so swapping one action for another reuses the right button rather than repainting the row
|
|
26
|
+
- **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
27
|
|
|
10
28
|
## [0.3.1] - 2026-05-13
|
|
11
29
|
|
package/README.md
CHANGED
|
@@ -73,11 +73,13 @@ import { sendToPlugin, createMessageHandler } from "figma-plugin-utilities/lib";
|
|
|
73
73
|
| `Header` | Header bar with `left`, `center`, `right` slots and optional title |
|
|
74
74
|
| `Footer` | Footer with `right`, `split`, and `full` layout variants |
|
|
75
75
|
| `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
|
|
78
|
-
| `LoadingState` |
|
|
79
|
-
| `FieldGroup` | Label + input wrapper
|
|
80
|
-
| `CheckboxCard` | Large checkbox with card styling and better touch targets |
|
|
76
|
+
| `EmptyState` | Empty/error states with optional icon and action buttons; `size`, `centered`, and `role="alert"` for failures |
|
|
77
|
+
| `ListItem` | Selectable list items with metadata and `badge` slots, an action menu (`menuOpen`, `menuToggle`, `menuClose`) |
|
|
78
|
+
| `LoadingState` | Centred message as `role="status"` (text only, no spinner) |
|
|
79
|
+
| `FieldGroup` | Label + input wrapper; `labelFor` binds the label to a text control |
|
|
80
|
+
| `CheckboxCard` | Large checkbox with card styling and better touch targets; `change` event |
|
|
81
|
+
|
|
82
|
+
Every component also takes a `class` (or `className`) prop.
|
|
81
83
|
|
|
82
84
|
### Header
|
|
83
85
|
|
|
@@ -231,11 +233,14 @@ const jsonResult = validateJsonString('{"key": "value"}');
|
|
|
231
233
|
// { valid: true, parsed: {...} } or { valid: false, error: "..." }
|
|
232
234
|
|
|
233
235
|
validateEmail("user@example.com"); // { valid: true }
|
|
234
|
-
validateNumber("42", { min: 0, max: 100 }); // { valid: true, value: 42 }
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
validateNumber("42", { min: 0, max: 100, integer: true }); // { valid: true, value: 42 }
|
|
237
|
+
validateUrl("", { required: false }); // { valid: true } — empty is allowed
|
|
238
|
+
validateJsonString(text, { maxSizeKB: 512, requireObject: true });
|
|
239
|
+
|
|
240
|
+
const clean = sanitizeName("My Plugin!!!", 200); // "My Plugin" — "Untitled" if nothing survives
|
|
241
|
+
sanitizeInput(input, 50); // stringify, truncate to maxLength, strip control characters, trim
|
|
242
|
+
// Note: sanitizeInput does NOT escape HTML. Escape at the point of rendering instead.
|
|
243
|
+
isEmpty(""); // true — also for [] and {}
|
|
239
244
|
```
|
|
240
245
|
|
|
241
246
|
### Error Handling (`lib/errorHandling.js`)
|
|
@@ -287,6 +292,26 @@ setDefaultWidth(320);
|
|
|
287
292
|
|
|
288
293
|
> **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
294
|
|
|
295
|
+
### Spec Frame Builders (`lib/figma-frame-builders.ts`)
|
|
296
|
+
|
|
297
|
+
Typed builders for canvas frames in a spec or documentation generator — auto-layout frames and components, text, token chips, colour swatches, table cells and headers, with light and dark palettes.
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
import {
|
|
301
|
+
specTokens, loadSpecFonts,
|
|
302
|
+
createAutoLayoutFrame, createAutoLayoutComponent, createText,
|
|
303
|
+
createTokenChip, createColorSwatch, createTableCell, createTableHeader,
|
|
304
|
+
} from "figma-plugin-utilities/lib/figma-frame-builders";
|
|
305
|
+
|
|
306
|
+
await loadSpecFonts(); // once, before drawing
|
|
307
|
+
const theme = specTokens.themes.dark;
|
|
308
|
+
|
|
309
|
+
const row = createAutoLayoutFrame({ name: "row", direction: "HORIZONTAL", spacing: 8, fill: theme.cellFill });
|
|
310
|
+
row.appendChild(createTokenChip({ label: "#FFFFFF", background: theme.chipBg, textColor: theme.text }));
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`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`.
|
|
314
|
+
|
|
290
315
|
### Figma Helpers (`lib/figma-helpers.ts`)
|
|
291
316
|
|
|
292
317
|
For use in `code.ts`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-plugin-utilities",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,11 @@
|
|
|
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"
|
|
31
36
|
}
|
|
32
37
|
},
|
|
33
38
|
"files": [
|
|
@@ -55,20 +60,20 @@
|
|
|
55
60
|
"author": "Marius Roosendaal",
|
|
56
61
|
"license": "MIT",
|
|
57
62
|
"devDependencies": {
|
|
58
|
-
"@eslint/js": "^9.39.
|
|
59
|
-
"@figma/plugin-typings": "^1.
|
|
63
|
+
"@eslint/js": "^9.39.2",
|
|
64
|
+
"@figma/plugin-typings": "^1.138.0",
|
|
60
65
|
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
|
61
66
|
"@types/node": "^22.13.4",
|
|
62
|
-
"eslint": "^9.39.
|
|
63
|
-
"eslint-plugin-svelte": "^
|
|
64
|
-
"figma-ui3-kit-svelte": "
|
|
65
|
-
"globals": "^
|
|
66
|
-
"prettier": "3.
|
|
67
|
+
"eslint": "^9.39.2",
|
|
68
|
+
"eslint-plugin-svelte": "^3.23.0",
|
|
69
|
+
"figma-ui3-kit-svelte": "^0.6.0",
|
|
70
|
+
"globals": "^17.12.0",
|
|
71
|
+
"prettier": "^3.9.8",
|
|
67
72
|
"prettier-plugin-svelte": "^3.4.0",
|
|
68
73
|
"svelte": "^4.2.20",
|
|
69
|
-
"svelte-eslint-parser": "^
|
|
74
|
+
"svelte-eslint-parser": "^1.8.1",
|
|
70
75
|
"typescript": "^5.9.3",
|
|
71
|
-
"typescript-eslint": "^8.
|
|
76
|
+
"typescript-eslint": "^8.70.0",
|
|
72
77
|
"vite": "^5.2.0"
|
|
73
78
|
},
|
|
74
79
|
"scripts": {
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
|
|
63
63
|
{#if normalizedActions && normalizedActions.length > 0}
|
|
64
64
|
<div class="empty-state__actions">
|
|
65
|
-
{#each normalizedActions as actionItem}
|
|
65
|
+
{#each normalizedActions as actionItem (actionItem.label)}
|
|
66
66
|
<Button variant="secondary" on:click={actionItem.handler}>
|
|
67
67
|
{actionItem.label}
|
|
68
68
|
</Button>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
/* The auto-dismiss below trips svelte/infinite-reactive-loop: the reactive
|
|
3
|
+
statement writes `visible`, and the timeout it schedules writes it again
|
|
4
|
+
through handleClose(). Neither reads `visible`, so the statement cannot
|
|
5
|
+
re-trigger itself — the rule only sees the shared assignment target. */
|
|
6
|
+
/* eslint-disable svelte/infinite-reactive-loop */
|
|
2
7
|
import { onDestroy, createEventDispatcher } from "svelte";
|
|
3
8
|
import { IconButton } from "figma-ui3-kit-svelte";
|
|
4
9
|
import { IconClose } from "figma-ui3-kit-svelte/icons";
|
|
@@ -43,7 +48,6 @@
|
|
|
43
48
|
} else {
|
|
44
49
|
visible = false;
|
|
45
50
|
}
|
|
46
|
-
|
|
47
51
|
function handleClose() {
|
|
48
52
|
visible = false;
|
|
49
53
|
clearTimeout(timeoutId);
|
|
@@ -82,6 +86,7 @@
|
|
|
82
86
|
align-items: center;
|
|
83
87
|
justify-content: space-between;
|
|
84
88
|
background: var(--figma-color-bg-secondary);
|
|
89
|
+
color: var(--figma-color-text);
|
|
85
90
|
font-size: var(--body-medium-font-size);
|
|
86
91
|
font-weight: var(--body-medium-font-weight);
|
|
87
92
|
letter-spacing: var(--body-medium-letter-spacing);
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
export type PaddingSpec =
|
|
2
|
+
number | { top?: number; right?: number; bottom?: number; left?: number };
|
|
3
|
+
|
|
4
|
+
export type SpecTheme = {
|
|
5
|
+
cellFill: RGB;
|
|
6
|
+
cellBorder: RGB;
|
|
7
|
+
text: RGB;
|
|
8
|
+
chipBg: RGB;
|
|
9
|
+
headerFill: RGB;
|
|
10
|
+
subheaderFill: RGB;
|
|
11
|
+
headingText: RGB;
|
|
12
|
+
/** Bottom rule under the header; the inverse header has none. */
|
|
13
|
+
headerBorder?: RGB;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type NodeKind = "frame" | "component";
|
|
17
|
+
export type NodeFor<K extends NodeKind> = K extends "component"
|
|
18
|
+
? ComponentNode
|
|
19
|
+
: FrameNode;
|
|
20
|
+
|
|
21
|
+
function rgb(r: number, g: number, b: number): RGB {
|
|
22
|
+
return { r, g, b };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createNode<K extends NodeKind>(as?: K): NodeFor<K> {
|
|
26
|
+
return (
|
|
27
|
+
as === "component" ? figma.createComponent() : figma.createFrame()
|
|
28
|
+
) as NodeFor<K>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function applyPadding(
|
|
32
|
+
frame: FrameNode | ComponentNode,
|
|
33
|
+
spec: PaddingSpec,
|
|
34
|
+
): void {
|
|
35
|
+
if (typeof spec === "number") {
|
|
36
|
+
frame.paddingTop = spec;
|
|
37
|
+
frame.paddingRight = spec;
|
|
38
|
+
frame.paddingBottom = spec;
|
|
39
|
+
frame.paddingLeft = spec;
|
|
40
|
+
} else {
|
|
41
|
+
frame.paddingTop = spec.top ?? 0;
|
|
42
|
+
frame.paddingRight = spec.right ?? 0;
|
|
43
|
+
frame.paddingBottom = spec.bottom ?? 0;
|
|
44
|
+
frame.paddingLeft = spec.left ?? 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const specTokens = {
|
|
49
|
+
accentColors: {
|
|
50
|
+
green: rgb(0.337, 0.757, 0.396), // #56C165 — AAA
|
|
51
|
+
blue: rgb(0.447, 0.682, 0.988), // #72AEFC — AA
|
|
52
|
+
purple: rgb(0.753, 0.608, 0.965), // #C09BF6 — AA18
|
|
53
|
+
red: rgb(0.98, 0.553, 0.569), // #FA8D91 — DNP
|
|
54
|
+
},
|
|
55
|
+
fonts: {
|
|
56
|
+
body: { family: "Inter", style: "Regular", size: 14 },
|
|
57
|
+
bodyBold: { family: "Inter", style: "Semi Bold", size: 14 },
|
|
58
|
+
subheading: { family: "Inter", style: "Medium", size: 24 },
|
|
59
|
+
heading: { family: "Inter", style: "Medium", size: 48 },
|
|
60
|
+
code: { family: "IBM Plex Mono", style: "Regular", size: 12 },
|
|
61
|
+
},
|
|
62
|
+
themes: {
|
|
63
|
+
light: {
|
|
64
|
+
cellFill: rgb(1.0, 1.0, 1.0), // #FFFFFF
|
|
65
|
+
cellBorder: rgb(0.949, 0.949, 0.949), // #F2F2F2
|
|
66
|
+
text: rgb(0.09, 0.09, 0.09), // #171717
|
|
67
|
+
chipBg: rgb(0.949, 0.949, 0.949), // #F2F2F2
|
|
68
|
+
headerFill: rgb(1.0, 1.0, 1.0), // #FFFFFF
|
|
69
|
+
subheaderFill: rgb(0.949, 0.949, 0.949), // #F2F2F2
|
|
70
|
+
headingText: rgb(0.09, 0.09, 0.09), // #171717
|
|
71
|
+
headerBorder: rgb(0.886, 0.886, 0.886), // #E2E2E2
|
|
72
|
+
} satisfies SpecTheme,
|
|
73
|
+
dark: {
|
|
74
|
+
cellFill: rgb(0.09, 0.09, 0.09), // #171717
|
|
75
|
+
cellBorder: rgb(0.114, 0.114, 0.114), // #1D1D1D
|
|
76
|
+
text: rgb(1.0, 1.0, 1.0), // #FFFFFF
|
|
77
|
+
chipBg: rgb(0.157, 0.157, 0.157), // #282828
|
|
78
|
+
headerFill: rgb(0.09, 0.09, 0.09), // #171717
|
|
79
|
+
subheaderFill: rgb(0.157, 0.157, 0.157), // #282828
|
|
80
|
+
headingText: rgb(1.0, 1.0, 1.0), // #FFFFFF
|
|
81
|
+
} satisfies SpecTheme,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export async function loadSpecFonts(): Promise<void> {
|
|
86
|
+
await Promise.all([
|
|
87
|
+
figma.loadFontAsync({ family: "Inter", style: "Regular" }),
|
|
88
|
+
figma.loadFontAsync({ family: "Inter", style: "Semi Bold" }),
|
|
89
|
+
figma.loadFontAsync({ family: "Inter", style: "Medium" }),
|
|
90
|
+
figma.loadFontAsync({ family: "IBM Plex Mono", style: "Regular" }),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type AutoLayoutOpts = {
|
|
95
|
+
name: string;
|
|
96
|
+
direction: "HORIZONTAL" | "VERTICAL" | "NONE";
|
|
97
|
+
spacing?: number;
|
|
98
|
+
padding?: PaddingSpec;
|
|
99
|
+
fill?: RGB;
|
|
100
|
+
cornerRadius?: number;
|
|
101
|
+
width?: number;
|
|
102
|
+
height?: number;
|
|
103
|
+
clipsContent?: boolean;
|
|
104
|
+
border?: { color: RGB; width?: number };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function applyAutoLayout(
|
|
108
|
+
node: FrameNode | ComponentNode,
|
|
109
|
+
opts: AutoLayoutOpts,
|
|
110
|
+
): void {
|
|
111
|
+
node.name = opts.name;
|
|
112
|
+
node.layoutMode = opts.direction;
|
|
113
|
+
node.fills = opts.fill ? [{ type: "SOLID", color: opts.fill }] : [];
|
|
114
|
+
|
|
115
|
+
if (opts.cornerRadius !== undefined) node.cornerRadius = opts.cornerRadius;
|
|
116
|
+
if (opts.clipsContent !== undefined) node.clipsContent = opts.clipsContent;
|
|
117
|
+
if (opts.padding !== undefined) applyPadding(node, opts.padding);
|
|
118
|
+
|
|
119
|
+
if (opts.direction !== "NONE") {
|
|
120
|
+
node.itemSpacing = opts.spacing ?? 0;
|
|
121
|
+
const isHorizontal = opts.direction === "HORIZONTAL";
|
|
122
|
+
node.primaryAxisSizingMode =
|
|
123
|
+
(isHorizontal ? opts.width : opts.height) !== undefined
|
|
124
|
+
? "FIXED"
|
|
125
|
+
: "AUTO";
|
|
126
|
+
node.counterAxisSizingMode =
|
|
127
|
+
(isHorizontal ? opts.height : opts.width) !== undefined
|
|
128
|
+
? "FIXED"
|
|
129
|
+
: "AUTO";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (opts.width !== undefined || opts.height !== undefined) {
|
|
133
|
+
node.resize(opts.width ?? node.width, opts.height ?? node.height);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (opts.border) {
|
|
137
|
+
node.strokes = [{ type: "SOLID", color: opts.border.color }];
|
|
138
|
+
node.strokeWeight = opts.border.width ?? 1;
|
|
139
|
+
node.strokeAlign = "CENTER";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createAutoLayoutFrame(opts: AutoLayoutOpts): FrameNode {
|
|
144
|
+
const frame = figma.createFrame();
|
|
145
|
+
applyAutoLayout(frame, opts);
|
|
146
|
+
return frame;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createAutoLayoutComponent(opts: AutoLayoutOpts): ComponentNode {
|
|
150
|
+
const component = figma.createComponent();
|
|
151
|
+
applyAutoLayout(component, opts);
|
|
152
|
+
return component;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createText(opts: {
|
|
156
|
+
characters: string;
|
|
157
|
+
font: { family: string; style: string; size: number };
|
|
158
|
+
color?: RGB;
|
|
159
|
+
lineHeight?: number;
|
|
160
|
+
letterSpacing?: number;
|
|
161
|
+
width?: number;
|
|
162
|
+
}): TextNode {
|
|
163
|
+
const node = figma.createText();
|
|
164
|
+
node.fontName = { family: opts.font.family, style: opts.font.style };
|
|
165
|
+
node.fontSize = opts.font.size;
|
|
166
|
+
|
|
167
|
+
if (opts.lineHeight !== undefined) {
|
|
168
|
+
node.lineHeight = { value: opts.lineHeight * 100, unit: "PERCENT" };
|
|
169
|
+
}
|
|
170
|
+
if (opts.letterSpacing !== undefined) {
|
|
171
|
+
node.letterSpacing = { value: opts.letterSpacing, unit: "PIXELS" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
node.characters = opts.characters;
|
|
175
|
+
node.fills = [
|
|
176
|
+
{ type: "SOLID", color: opts.color ?? specTokens.themes.light.text },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
if (opts.width !== undefined) {
|
|
180
|
+
node.textAutoResize = "HEIGHT";
|
|
181
|
+
node.resize(opts.width, node.height);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return node;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createTokenChip<K extends NodeKind = "frame">(opts: {
|
|
188
|
+
label: string;
|
|
189
|
+
background: RGB;
|
|
190
|
+
textColor?: RGB;
|
|
191
|
+
width?: number;
|
|
192
|
+
as?: K;
|
|
193
|
+
}): NodeFor<K> {
|
|
194
|
+
const node = createNode(opts.as);
|
|
195
|
+
applyAutoLayout(node, {
|
|
196
|
+
name: "label",
|
|
197
|
+
direction: "VERTICAL",
|
|
198
|
+
padding: { right: 4, left: 4 },
|
|
199
|
+
fill: opts.background,
|
|
200
|
+
cornerRadius: 2,
|
|
201
|
+
height: 24,
|
|
202
|
+
width: opts.width,
|
|
203
|
+
});
|
|
204
|
+
node.primaryAxisAlignItems = "CENTER";
|
|
205
|
+
const text = createText({
|
|
206
|
+
characters: opts.label,
|
|
207
|
+
font: specTokens.fonts.code,
|
|
208
|
+
color: opts.textColor ?? specTokens.themes.light.text,
|
|
209
|
+
lineHeight: 1.4,
|
|
210
|
+
letterSpacing: 0.18,
|
|
211
|
+
});
|
|
212
|
+
text.name = "label";
|
|
213
|
+
text.textAutoResize = "WIDTH_AND_HEIGHT";
|
|
214
|
+
node.appendChild(text);
|
|
215
|
+
return node as NodeFor<K>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function createColorSwatch<K extends NodeKind = "frame">(opts: {
|
|
219
|
+
color: RGB;
|
|
220
|
+
size?: number;
|
|
221
|
+
cornerRadius?: number;
|
|
222
|
+
inverse?: boolean;
|
|
223
|
+
as?: K;
|
|
224
|
+
}): NodeFor<K> {
|
|
225
|
+
const size = opts.size ?? 40;
|
|
226
|
+
const node = createNode(opts.as);
|
|
227
|
+
applyAutoLayout(node, {
|
|
228
|
+
name: "swatch",
|
|
229
|
+
direction: "NONE",
|
|
230
|
+
fill: opts.color,
|
|
231
|
+
cornerRadius: opts.cornerRadius ?? 2,
|
|
232
|
+
width: size,
|
|
233
|
+
height: size,
|
|
234
|
+
});
|
|
235
|
+
node.strokes = [
|
|
236
|
+
{
|
|
237
|
+
type: "SOLID",
|
|
238
|
+
color: opts.inverse ? rgb(1, 1, 1) : rgb(0, 0, 0),
|
|
239
|
+
opacity: 0.1,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
node.strokeWeight = 1;
|
|
243
|
+
node.strokeAlign = "INSIDE";
|
|
244
|
+
return node as NodeFor<K>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function chipOrInstance(
|
|
248
|
+
source: ComponentNode | undefined,
|
|
249
|
+
label: string,
|
|
250
|
+
background: RGB,
|
|
251
|
+
textColor: RGB,
|
|
252
|
+
): FrameNode | InstanceNode {
|
|
253
|
+
if (source) return source.createInstance();
|
|
254
|
+
return createTokenChip({ label, background, textColor });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The library cell holds its chips in a "tokens" slot, a hugging row.
|
|
258
|
+
function tokensRow(chip: FrameNode | InstanceNode): FrameNode {
|
|
259
|
+
const row = createAutoLayoutFrame({
|
|
260
|
+
name: "tokens",
|
|
261
|
+
direction: "HORIZONTAL",
|
|
262
|
+
spacing: 4,
|
|
263
|
+
});
|
|
264
|
+
row.appendChild(chip);
|
|
265
|
+
return row;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function swatchOrInstance(
|
|
269
|
+
source: ComponentNode | undefined,
|
|
270
|
+
color: RGB,
|
|
271
|
+
inverse: boolean,
|
|
272
|
+
): FrameNode | InstanceNode {
|
|
273
|
+
if (source) return source.createInstance();
|
|
274
|
+
return createColorSwatch({ color, inverse });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function createTableCell<K extends NodeKind = "frame">(opts: {
|
|
278
|
+
variant: "text" | "header" | "token";
|
|
279
|
+
theme?: SpecTheme;
|
|
280
|
+
swatch?: boolean;
|
|
281
|
+
text?: string;
|
|
282
|
+
chipLabel?: string;
|
|
283
|
+
chipBackground?: RGB;
|
|
284
|
+
swatchColor?: RGB;
|
|
285
|
+
chipSource?: ComponentNode;
|
|
286
|
+
swatchSource?: ComponentNode;
|
|
287
|
+
width?: number;
|
|
288
|
+
height?: number;
|
|
289
|
+
textSizing?: "fill" | "hug";
|
|
290
|
+
as?: K;
|
|
291
|
+
}): NodeFor<K> {
|
|
292
|
+
const theme = opts.theme ?? specTokens.themes.light;
|
|
293
|
+
const border = { color: theme.cellBorder };
|
|
294
|
+
const isTokenSwatch = opts.variant === "token" && opts.swatch;
|
|
295
|
+
|
|
296
|
+
if (isTokenSwatch) {
|
|
297
|
+
const node = createNode(opts.as);
|
|
298
|
+
applyAutoLayout(node, {
|
|
299
|
+
name: "table-cell",
|
|
300
|
+
direction: "HORIZONTAL",
|
|
301
|
+
padding: { top: 12, right: 20, bottom: 12, left: 20 },
|
|
302
|
+
fill: theme.cellFill,
|
|
303
|
+
width: opts.width ?? 240,
|
|
304
|
+
height: opts.height ?? 72,
|
|
305
|
+
border,
|
|
306
|
+
});
|
|
307
|
+
node.primaryAxisAlignItems = "SPACE_BETWEEN";
|
|
308
|
+
node.counterAxisAlignItems = "MIN";
|
|
309
|
+
node.appendChild(
|
|
310
|
+
tokensRow(
|
|
311
|
+
chipOrInstance(
|
|
312
|
+
opts.chipSource,
|
|
313
|
+
opts.chipLabel ?? "",
|
|
314
|
+
opts.chipBackground ?? theme.chipBg,
|
|
315
|
+
theme.text,
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
node.appendChild(
|
|
320
|
+
swatchOrInstance(
|
|
321
|
+
opts.swatchSource,
|
|
322
|
+
opts.swatchColor ?? rgb(0, 0, 0),
|
|
323
|
+
theme.cellFill.r < 0.5,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
return node as NodeFor<K>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const isTextSwatch = opts.variant === "text" && opts.swatch;
|
|
330
|
+
const defaultHeight = isTextSwatch ? 72 : 56;
|
|
331
|
+
|
|
332
|
+
const node = createNode(opts.as);
|
|
333
|
+
applyAutoLayout(node, {
|
|
334
|
+
name: "table-cell",
|
|
335
|
+
direction: "HORIZONTAL",
|
|
336
|
+
spacing: opts.variant === "token" ? 8 : isTextSwatch ? 16 : 0,
|
|
337
|
+
padding: { top: 12, right: 20, bottom: 16, left: 20 },
|
|
338
|
+
fill: theme.cellFill,
|
|
339
|
+
width: opts.width ?? 240,
|
|
340
|
+
height: opts.height ?? defaultHeight,
|
|
341
|
+
border,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (isTextSwatch) {
|
|
345
|
+
node.primaryAxisAlignItems = "SPACE_BETWEEN";
|
|
346
|
+
node.counterAxisAlignItems = "MIN";
|
|
347
|
+
const label = createText({
|
|
348
|
+
characters: opts.text ?? "",
|
|
349
|
+
font: specTokens.fonts.body,
|
|
350
|
+
color: theme.text,
|
|
351
|
+
lineHeight: 1.4,
|
|
352
|
+
width: 144,
|
|
353
|
+
});
|
|
354
|
+
label.name = "text";
|
|
355
|
+
node.appendChild(label);
|
|
356
|
+
node.appendChild(
|
|
357
|
+
swatchOrInstance(
|
|
358
|
+
opts.swatchSource,
|
|
359
|
+
opts.swatchColor ?? rgb(0, 0, 0),
|
|
360
|
+
theme.cellFill.r < 0.5,
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
return node as NodeFor<K>;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (opts.variant === "token") {
|
|
367
|
+
node.appendChild(
|
|
368
|
+
tokensRow(
|
|
369
|
+
chipOrInstance(
|
|
370
|
+
opts.chipSource,
|
|
371
|
+
opts.chipLabel ?? "",
|
|
372
|
+
opts.chipBackground ?? theme.chipBg,
|
|
373
|
+
theme.text,
|
|
374
|
+
),
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
return node as NodeFor<K>;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// "text" and "header" variants
|
|
381
|
+
const isBold = opts.variant === "header";
|
|
382
|
+
const label = createText({
|
|
383
|
+
characters: opts.text ?? "",
|
|
384
|
+
font: isBold ? specTokens.fonts.bodyBold : specTokens.fonts.body,
|
|
385
|
+
color: theme.text,
|
|
386
|
+
lineHeight: 1.4,
|
|
387
|
+
});
|
|
388
|
+
label.name = "text";
|
|
389
|
+
if (opts.textSizing === "hug") {
|
|
390
|
+
label.textAutoResize = "WIDTH_AND_HEIGHT";
|
|
391
|
+
} else {
|
|
392
|
+
label.layoutGrow = 1;
|
|
393
|
+
label.textAutoResize = "HEIGHT";
|
|
394
|
+
}
|
|
395
|
+
node.appendChild(label);
|
|
396
|
+
|
|
397
|
+
return node as NodeFor<K>;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function createTableHeader<K extends NodeKind = "frame">(opts: {
|
|
401
|
+
variant: "header" | "subheader";
|
|
402
|
+
theme?: SpecTheme;
|
|
403
|
+
title?: string;
|
|
404
|
+
width?: number;
|
|
405
|
+
height?: number;
|
|
406
|
+
as?: K;
|
|
407
|
+
}): NodeFor<K> {
|
|
408
|
+
const theme = opts.theme ?? specTokens.themes.light;
|
|
409
|
+
const title = opts.title ?? "";
|
|
410
|
+
|
|
411
|
+
if (opts.variant === "header") {
|
|
412
|
+
const node = createNode(opts.as);
|
|
413
|
+
applyAutoLayout(node, {
|
|
414
|
+
name: "table-header",
|
|
415
|
+
direction: "VERTICAL",
|
|
416
|
+
padding: { top: 16, right: 20, bottom: 16, left: 20 },
|
|
417
|
+
width: opts.width ?? 960,
|
|
418
|
+
height: opts.height ?? 160,
|
|
419
|
+
fill: theme.headerFill,
|
|
420
|
+
});
|
|
421
|
+
if (theme.headerBorder) {
|
|
422
|
+
node.strokes = [{ type: "SOLID", color: theme.headerBorder }];
|
|
423
|
+
node.strokeTopWeight = 0;
|
|
424
|
+
node.strokeRightWeight = 0;
|
|
425
|
+
node.strokeBottomWeight = 1;
|
|
426
|
+
node.strokeLeftWeight = 0;
|
|
427
|
+
node.strokeAlign = "INSIDE";
|
|
428
|
+
}
|
|
429
|
+
const text = createText({
|
|
430
|
+
characters: title,
|
|
431
|
+
font: specTokens.fonts.heading,
|
|
432
|
+
color: theme.headingText,
|
|
433
|
+
lineHeight: 1,
|
|
434
|
+
letterSpacing: -1.92,
|
|
435
|
+
});
|
|
436
|
+
text.name = "title";
|
|
437
|
+
node.appendChild(text);
|
|
438
|
+
text.layoutSizingHorizontal = "FILL";
|
|
439
|
+
text.textAutoResize = "HEIGHT";
|
|
440
|
+
return node as NodeFor<K>;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// subheader: the title sits on the bottom edge
|
|
444
|
+
const node = createNode(opts.as);
|
|
445
|
+
applyAutoLayout(node, {
|
|
446
|
+
name: "table-subheader",
|
|
447
|
+
direction: "VERTICAL",
|
|
448
|
+
spacing: 8,
|
|
449
|
+
padding: { top: 16, right: 20, bottom: 16, left: 20 },
|
|
450
|
+
fill: theme.subheaderFill,
|
|
451
|
+
width: opts.width ?? 960,
|
|
452
|
+
height: opts.height ?? 96,
|
|
453
|
+
});
|
|
454
|
+
node.primaryAxisAlignItems = "MAX";
|
|
455
|
+
const text = createText({
|
|
456
|
+
characters: title,
|
|
457
|
+
font: specTokens.fonts.subheading,
|
|
458
|
+
color: theme.headingText,
|
|
459
|
+
lineHeight: 1.3,
|
|
460
|
+
letterSpacing: -0.48,
|
|
461
|
+
});
|
|
462
|
+
text.name = "title";
|
|
463
|
+
node.appendChild(text);
|
|
464
|
+
text.layoutSizingHorizontal = "FILL";
|
|
465
|
+
text.textAutoResize = "HEIGHT";
|
|
466
|
+
return node as NodeFor<K>;
|
|
467
|
+
}
|