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/src/index.js CHANGED
@@ -7,8 +7,17 @@ export {
7
7
  ListItem,
8
8
  LoadingState,
9
9
  PluginLayout,
10
+ Section,
10
11
  StatusBar,
12
+ DataTable,
11
13
  Header,
14
+ CodeExportModal,
15
+ FieldGrid,
16
+ LadderBadges,
17
+ SteppedField,
18
+ RampCurve,
19
+ MappingChip,
20
+ ConfirmModal,
12
21
  } from "./components/index.js";
13
22
 
14
23
  // Re-export all utilities
@@ -19,9 +28,14 @@ export {
19
28
  // Colors
20
29
  rgbToHex,
21
30
  hexToRgb,
31
+ isValidHex,
22
32
  getLuminance,
23
33
  getContrastRatio,
24
34
  meetsContrastLevel,
35
+ // Copy
36
+ plural,
37
+ UNDO,
38
+ joinList,
25
39
  // Validation
26
40
  validateUrl,
27
41
  validateJsonString,
@@ -38,12 +52,14 @@ export {
38
52
  withErrorHandling,
39
53
  safeAsync,
40
54
  parseJsonSafe,
41
- notifyError,
42
- notifySuccess,
43
- notifyWarning,
44
55
  // Resize
45
56
  setDefaultWidth,
46
57
  getContentHeight,
47
58
  resizeToFit,
48
59
  autoResize,
60
+ // Confirmation dialogs
61
+ confirmAction,
62
+ confirmDiscardChanges,
63
+ answerConfirm,
64
+ confirmRequest,
49
65
  } from "./lib/index.js";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Color utilities for Figma plugins. No Figma API, so either thread may
3
+ * import them — but in a plugin that builds both threads in one pass, only
4
+ * one of them, or Vite splits this module into a chunk the sandbox can't load.
5
+ */
6
+
7
+ /** A color with channels 0–1, as Figma's RGB and RGBA. */
8
+ export type Rgb = { r: number; g: number; b: number; a?: number };
9
+
10
+ export type HexOptions = {
11
+ /** Lowercase digits; uppercase by default */
12
+ lowercase?: boolean;
13
+ /** Prefix with "#"; true by default */
14
+ hash?: boolean;
15
+ /** Append the alpha byte when `a` is below 1 */
16
+ alpha?: boolean;
17
+ };
18
+
19
+ const byte = (channel: number) =>
20
+ Math.round(Math.min(1, Math.max(0, channel)) * 255)
21
+ .toString(16)
22
+ .padStart(2, "0");
23
+
24
+ /**
25
+ * Convert an RGB color (0–1 channels, clamped) to a HEX string, "#FF0000" by
26
+ * default.
27
+ */
28
+ export function rgbToHex(
29
+ { r, g, b, a }: Rgb,
30
+ { lowercase = false, hash = true, alpha = false }: HexOptions = {},
31
+ ): string {
32
+ let hex = byte(r) + byte(g) + byte(b);
33
+ if (alpha && a !== undefined && a < 1) hex += byte(a);
34
+ if (!lowercase) hex = hex.toUpperCase();
35
+ return hash ? `#${hex}` : hex;
36
+ }
37
+
38
+ /**
39
+ * Whether a string is a six-digit HEX color, in either case. The "#" is
40
+ * required unless `requireHash` is false.
41
+ */
42
+ export function isValidHex(
43
+ value: string,
44
+ { requireHash = true }: { requireHash?: boolean } = {},
45
+ ): boolean {
46
+ return (requireHash ? /^#[0-9a-f]{6}$/i : /^#?[0-9a-f]{6}$/i).test(value);
47
+ }
48
+
49
+ /**
50
+ * Convert a HEX string ("#FF0000" or "FF0000") to an RGB color with 0–1
51
+ * channels, or null if it isn't six HEX digits.
52
+ */
53
+ export function hexToRgb(
54
+ hex: string,
55
+ ): { r: number; g: number; b: number } | null {
56
+ const cleanHex = hex.replace(/^#/, "");
57
+ if (!/^[0-9A-Fa-f]{6}$/.test(cleanHex)) {
58
+ return null;
59
+ }
60
+ const r = parseInt(cleanHex.substring(0, 2), 16) / 255;
61
+ const g = parseInt(cleanHex.substring(2, 4), 16) / 255;
62
+ const b = parseInt(cleanHex.substring(4, 6), 16) / 255;
63
+ return { r, g, b };
64
+ }
65
+
66
+ /** Relative luminance of a color (0–1), for contrast calculations. */
67
+ export function getLuminance({ r, g, b }: Rgb): number {
68
+ const adjust = (c: number) =>
69
+ c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
70
+ return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b);
71
+ }
72
+
73
+ /** WCAG contrast ratio between two colors (1–21). */
74
+ export function getContrastRatio(color1: Rgb, color2: Rgb): number {
75
+ const l1 = getLuminance(color1);
76
+ const l2 = getLuminance(color2);
77
+ const lighter = Math.max(l1, l2);
78
+ const darker = Math.min(l1, l2);
79
+ return (lighter + 0.05) / (darker + 0.05);
80
+ }
81
+
82
+ /** Whether a contrast ratio meets a WCAG level. */
83
+ export function meetsContrastLevel(
84
+ ratio: number,
85
+ level: "AA" | "AAA" | "AA-large" | "AAA-large",
86
+ ): boolean {
87
+ switch (level) {
88
+ case "AAA":
89
+ return ratio >= 7;
90
+ case "AAA-large":
91
+ return ratio >= 4.5;
92
+ case "AA":
93
+ return ratio >= 4.5;
94
+ case "AA-large":
95
+ return ratio >= 3;
96
+ default:
97
+ return false;
98
+ }
99
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Confirmation dialogs, in place of the browser's confirm(): mount one
3
+ * ConfirmModal in the plugin's UI and it shows whatever confirmAction() asks.
4
+ */
5
+
6
+ import { get, writable } from "svelte/store";
7
+
8
+ export type ConfirmOptions = {
9
+ title: string;
10
+ message: string;
11
+ confirmLabel: string;
12
+ cancelLabel?: string;
13
+ destructive?: boolean;
14
+ };
15
+
16
+ type ConfirmRequest = ConfirmOptions & {
17
+ resolve: (confirmed: boolean) => void;
18
+ };
19
+
20
+ export const confirmRequest = writable<ConfirmRequest | null>(null);
21
+
22
+ /**
23
+ * Ask the user to confirm. Resolves true on confirm; false on cancel,
24
+ * Escape, a click outside, or when another confirmation replaces this one.
25
+ */
26
+ export function confirmAction(options: ConfirmOptions): Promise<boolean> {
27
+ get(confirmRequest)?.resolve(false);
28
+ return new Promise((resolve) => {
29
+ confirmRequest.set({ ...options, resolve });
30
+ });
31
+ }
32
+
33
+ /** Answer the open confirmation, if any */
34
+ export function answerConfirm(confirmed: boolean): void {
35
+ const request = get(confirmRequest);
36
+ if (!request) return;
37
+ confirmRequest.set(null);
38
+ request.resolve(confirmed);
39
+ }
40
+
41
+ /**
42
+ * The prompt before unsaved edits are thrown away: closing a panel or
43
+ * leaving an editor. Resolves true to discard them.
44
+ */
45
+ export function confirmDiscardChanges(
46
+ message = "Your changes haven't been saved.",
47
+ ): Promise<boolean> {
48
+ return confirmAction({
49
+ title: "Discard changes?",
50
+ message,
51
+ confirmLabel: "Discard changes",
52
+ cancelLabel: "Keep editing",
53
+ destructive: true,
54
+ });
55
+ }
@@ -145,42 +145,3 @@ export function parseJsonSafe(jsonString) {
145
145
  return { ok: false, error: message };
146
146
  }
147
147
  }
148
-
149
- /**
150
- * Show an error notification to the user with standardized options.
151
- * Use this in code.ts files for consistent error notifications.
152
- * @param {string} message - The error message to display
153
- * @param {unknown} [error] - Optional error object for logging
154
- * @param {string} [context] - Optional context for logging
155
- */
156
- export function notifyError(message, error, context) {
157
- if (error) {
158
- logError(error, context || message);
159
- }
160
- // @ts-ignore - figma global is available in plugin context
161
- if (typeof figma !== "undefined") {
162
- figma.notify(message, { error: true });
163
- }
164
- }
165
-
166
- /**
167
- * Show a success notification to the user with standardized options.
168
- * @param {string} message - The success message to display
169
- */
170
- export function notifySuccess(message) {
171
- // @ts-ignore - figma global is available in plugin context
172
- if (typeof figma !== "undefined") {
173
- figma.notify(message);
174
- }
175
- }
176
-
177
- /**
178
- * Show a warning notification to the user.
179
- * @param {string} message - The warning message to display
180
- */
181
- export function notifyWarning(message) {
182
- // @ts-ignore - figma global is available in plugin context
183
- if (typeof figma !== "undefined") {
184
- figma.notify(message, { timeout: 5000 });
185
- }
186
- }
@@ -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.251, 0.769, 0.349), // #40C459 — 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: 6, left: 6 },
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
+ }