figma-plugin-utilities 0.4.0 → 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.
@@ -0,0 +1,349 @@
1
+ <!--
2
+ DataTable: named rows with a cell per column, laid out as a table — a
3
+ set's size at each breakpoint, or a style's size and line height before
4
+ and after an update. The name leads each row, with its notes as badges
5
+ after it: the first `maxBadges`, and a count of the rest listed in its
6
+ title. A cell is a badge, a variable chip where it aliases a variable, or
7
+ plain text; badges and text are colored as new, changed or danger.
8
+
9
+ Selectable (the default), rows are buttons, named by their `label`, that
10
+ open the `editor` slot under them, with a trailing button in `action`.
11
+ Not selectable, the table is read-only, with table roles. Removed rows are
12
+ colored and can't be selected either way.
13
+ -->
14
+ <script>
15
+ import { createEventDispatcher } from "svelte";
16
+ import { Badge, Tooltip, VariablePill } from "figma-ui3-kit-svelte";
17
+
18
+ /**
19
+ * @typedef {{
20
+ * label: string,
21
+ * title?: string,
22
+ * width?: string,
23
+ * align?: "start" | "end",
24
+ * }} Column `width` is a grid track, 2.75rem by default; `align` "start"
25
+ * by default, "end" to right-align numbers.
26
+ * @typedef {{
27
+ * text: string | number,
28
+ * tone?: "new" | "changed" | "danger" | "muted" | null,
29
+ * title?: string | null,
30
+ * alias?: string | null,
31
+ * plain?: boolean,
32
+ * }} Cell `alias` names the variable the value aliases, shown as a chip;
33
+ * `plain` shows the text as it is, not as a badge (muted cells are).
34
+ * @typedef {{
35
+ * key: string,
36
+ * name: string,
37
+ * label?: string,
38
+ * cells: Cell[],
39
+ * tone?: "new" | null,
40
+ * removed?: boolean,
41
+ * badges?: { text: string, variant?: string, title?: string }[],
42
+ * [extra: string]: any,
43
+ * }} Row
44
+ */
45
+
46
+ /** @type {(Column | string)[]} The columns after the name. */
47
+ export let columns = [];
48
+ /** @type {Row[]} */
49
+ export let rows = [];
50
+ /** The name column's header. */
51
+ export let nameLabel = "Name";
52
+ /** Rows are buttons that open an editor; false makes the table read-only. */
53
+ export let selectable = true;
54
+ /** @type {string | null} The row whose editor is open. */
55
+ export let selectedKey = null;
56
+ /** @type {number | null} A column to mark, header and cells. */
57
+ export let active = null;
58
+ /** Makes the column headers buttons that dispatch `column`. */
59
+ export let selectableColumns = false;
60
+ /**
61
+ * @type {string | null} The container's side padding, such as
62
+ * "var(--size-xsmall)": the rows run through it to the container's edges,
63
+ * their contents still in line with the rest of it.
64
+ */
65
+ export let inset = null;
66
+ /** How many of a row's badges show before the rest become a count. */
67
+ export let maxBadges = 2;
68
+ /** @type {string | null} Names a read-only table. */
69
+ export let ariaLabel = null;
70
+
71
+ let className = "";
72
+ export { className as class };
73
+
74
+ const dispatch = createEventDispatcher();
75
+
76
+ // The badge a value gets for its tone.
77
+ const TONES = { new: "success", changed: "warning", danger: "danger" };
78
+
79
+ // The limit is passed in, so the markup re-runs when it changes.
80
+ const shown = (row, max) => (row.badges ?? []).slice(0, max);
81
+ const hidden = (row, max) => (row.badges ?? []).slice(max);
82
+
83
+ $: cols = columns.map((c) => (typeof c === "string" ? { label: c } : c));
84
+ $: tracks = cols.map((c) => c.width ?? "2.75rem").join(" ");
85
+ // Read-only, the table has table roles; selectable, its rows are buttons
86
+ // named by their label, and the header is for sight only unless it picks.
87
+ $: roles = !selectable;
88
+ $: headerHidden = !roles && !selectableColumns;
89
+ </script>
90
+
91
+ <div
92
+ class="table {className}"
93
+ class:static={!selectable}
94
+ style:--tracks={tracks}
95
+ style:--inset={inset}
96
+ role={roles ? "table" : undefined}
97
+ aria-label={roles ? ariaLabel : undefined}
98
+ >
99
+ <div class="row-wrap" aria-hidden={headerHidden ? "true" : undefined}>
100
+ <div class="tr th" role={roles ? "row" : undefined}>
101
+ <span
102
+ role={roles ? "columnheader" : undefined}
103
+ aria-hidden={roles ? undefined : "true"}>{nameLabel}</span
104
+ >
105
+ {#each cols as col, c (col.label)}
106
+ {#if selectableColumns}
107
+ <button
108
+ type="button"
109
+ class="num col"
110
+ class:end={col.align === "end"}
111
+ class:active={c === active}
112
+ aria-label="Show {col.label}"
113
+ aria-pressed={c === active}
114
+ title={col.title ?? null}
115
+ on:click={() => dispatch("column", c)}>{col.label}</button
116
+ >
117
+ {:else}
118
+ <span
119
+ class="num"
120
+ class:end={col.align === "end"}
121
+ class:active={c === active}
122
+ role={roles ? "columnheader" : undefined}
123
+ title={col.title ?? null}>{col.label}</span
124
+ >
125
+ {/if}
126
+ {/each}
127
+ </div>
128
+ <span></span>
129
+ </div>
130
+ {#each rows as row (row.key)}
131
+ {@const interactive = selectable && !row.removed}
132
+ {@const selected = interactive && selectedKey === row.key}
133
+ <div class="row-wrap" class:item={interactive} class:selected>
134
+ <svelte:element
135
+ this={interactive ? "button" : "div"}
136
+ type={interactive ? "button" : undefined}
137
+ class="tr"
138
+ class:row={interactive}
139
+ class:removed={row.removed}
140
+ role={roles ? "row" : undefined}
141
+ aria-label={roles ? undefined : row.label}
142
+ aria-expanded={interactive ? selected : undefined}
143
+ on:click={() => interactive && dispatch("select", row)}
144
+ >
145
+ <span class="token" role={roles ? "cell" : undefined}>
146
+ <span class="name" class:is-new={row.tone === "new"}>{row.name}</span>
147
+ <!-- Tooltips, not titles: a plugin's frame may not show a title. -->
148
+ {#each shown(row, maxBadges) as badge, i (i)}
149
+ {#if badge.title}
150
+ <Tooltip label={badge.title} direction="Top">
151
+ <Badge variant={badge.variant ?? "default"} text={badge.text} />
152
+ </Tooltip>
153
+ {:else}
154
+ <Badge variant={badge.variant ?? "default"} text={badge.text} />
155
+ {/if}
156
+ {/each}
157
+ {#if hidden(row, maxBadges).length}
158
+ <Tooltip
159
+ label={hidden(row, maxBadges)
160
+ .map((b) => b.text)
161
+ .join(", ")}
162
+ direction="Top"
163
+ >
164
+ <Badge text="+{hidden(row, maxBadges).length}" />
165
+ </Tooltip>
166
+ {/if}
167
+ </span>
168
+ {#each row.cells as cell, c (c)}
169
+ {@const plain = cell.plain || cell.tone === "muted"}
170
+ <span
171
+ class="num"
172
+ class:end={cols[c]?.align === "end"}
173
+ class:active={c === active}
174
+ class:plain
175
+ class:muted={cell.tone === "muted"}
176
+ class:text-new={plain && cell.tone === "new"}
177
+ class:text-changed={plain && cell.tone === "changed"}
178
+ class:text-danger={plain && cell.tone === "danger"}
179
+ role={roles ? "cell" : undefined}
180
+ title={[cell.alias, cell.title].filter(Boolean).join(", ") || null}
181
+ >
182
+ {#if plain}
183
+ {cell.text}
184
+ {:else if cell.alias && !row.removed}
185
+ <!-- No label, so no title of its own over the cell's. -->
186
+ <VariablePill
187
+ label={null}
188
+ class={cell.tone ? `tone-${cell.tone}` : ""}
189
+ >{cell.text}</VariablePill
190
+ >
191
+ {:else}
192
+ <Badge
193
+ variant={row.removed
194
+ ? "danger"
195
+ : (TONES[cell.tone] ?? "default")}
196
+ text={String(cell.text)}
197
+ />
198
+ {/if}
199
+ </span>
200
+ {/each}
201
+ </svelte:element>
202
+ <span class="action"><slot name="action" {row} /></span>
203
+ </div>
204
+ {#if selected}
205
+ <div class="editor"><slot name="editor" {row} /></div>
206
+ {/if}
207
+ {/each}
208
+ </div>
209
+ <slot name="note" />
210
+
211
+ <style>
212
+ /* Run through the container's padding. Rows keep their own 8px inside,
213
+ so the name lines up with the container's content; the action ends 8px
214
+ from the edge, in line with a Header's icons. */
215
+ .table {
216
+ display: flex;
217
+ flex-direction: column;
218
+ margin-inline: calc(-1 * var(--inset, 0px));
219
+ font-family: var(--font-stack);
220
+ font-size: var(--body-medium-font-size);
221
+ font-weight: var(--body-medium-font-weight);
222
+ line-height: var(--body-medium-line-height);
223
+ letter-spacing: var(--body-medium-letter-spacing);
224
+ }
225
+ .tr {
226
+ display: grid;
227
+ grid-template-columns: minmax(0, 1fr) var(--tracks,);
228
+ gap: 4px;
229
+ align-items: center;
230
+ padding: var(--size-xxxsmall) var(--size-xxsmall);
231
+ border: none;
232
+ background: none;
233
+ color: var(--figma-color-text);
234
+ font: inherit;
235
+ letter-spacing: inherit;
236
+ text-align: left;
237
+ }
238
+ /* A row and its action side by side; the button can't sit in the row,
239
+ which is a button itself. */
240
+ /* 32px rows, header included, their rule counted in. */
241
+ .row-wrap {
242
+ box-sizing: border-box;
243
+ display: grid;
244
+ grid-template-columns: minmax(0, 1fr) var(--size-small);
245
+ column-gap: var(--size-xxxsmall);
246
+ align-items: center;
247
+ min-height: var(--size-medium);
248
+ padding-inline: max(0px, calc(var(--inset, 0px) - var(--size-xxsmall)));
249
+ border-bottom: 1px solid var(--figma-color-border);
250
+ }
251
+ .editor {
252
+ padding-inline: max(0px, calc(var(--inset, 0px) - var(--size-xxsmall)));
253
+ border-bottom: 1px solid var(--figma-color-border);
254
+ }
255
+ .action {
256
+ display: flex;
257
+ }
258
+ /* Hover and the open row share one gray, the action included. */
259
+ .row {
260
+ width: 100%;
261
+ cursor: pointer;
262
+ }
263
+ .item:hover,
264
+ .item.selected {
265
+ background: var(--figma-color-bg-secondary);
266
+ }
267
+ .row:focus-visible,
268
+ .col:focus-visible {
269
+ outline: 1px solid var(--figma-color-border-selected);
270
+ outline-offset: -1px;
271
+ }
272
+ /* Body medium strong, as Text's body-medium-strong; the table already
273
+ sets the body medium size, line height and letter spacing. */
274
+ .th {
275
+ color: var(--figma-color-text);
276
+ font-weight: var(--font-weight-strong);
277
+ }
278
+ .token {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 6px;
282
+ min-width: 0;
283
+ overflow: hidden;
284
+ white-space: nowrap;
285
+ text-overflow: ellipsis;
286
+ }
287
+ /* Padded and rounded, so the marked column's highlight is a pill inside
288
+ the row; the row keeps its height. */
289
+ .num {
290
+ padding: 3px var(--size-xxxsmall);
291
+ border-radius: var(--border-radius-medium);
292
+ text-align: left;
293
+ }
294
+ /* Tabular figures line up plain numbers in a column; a badge or chip is
295
+ sized to its own text, where they'd only widen a narrow 1. */
296
+ .num.plain {
297
+ font-variant-numeric: tabular-nums;
298
+ }
299
+ .col {
300
+ border: none;
301
+ background: none;
302
+ color: inherit;
303
+ font: inherit;
304
+ letter-spacing: inherit;
305
+ cursor: pointer;
306
+ }
307
+ .col:hover:not(.active) {
308
+ background: var(--figma-color-bg-hover);
309
+ }
310
+ .active {
311
+ background: var(--figma-color-bg-selected-secondary);
312
+ }
313
+ .is-new {
314
+ color: var(--figma-color-text-success);
315
+ }
316
+ .removed {
317
+ color: var(--figma-color-text-danger);
318
+ }
319
+ /* A chip has no colored variants; its text takes the badge's color. */
320
+ .num :global(.variable-pill.tone-new) {
321
+ color: var(--figma-color-text-success);
322
+ }
323
+ .num :global(.variable-pill.tone-changed) {
324
+ color: var(--figma-color-text-warning);
325
+ }
326
+ .muted {
327
+ color: var(--figma-color-text-secondary);
328
+ }
329
+ .text-new {
330
+ color: var(--figma-color-text-success);
331
+ }
332
+ .text-changed {
333
+ color: var(--figma-color-text-warning);
334
+ }
335
+ .text-danger {
336
+ color: var(--figma-color-text-danger);
337
+ }
338
+ /* Columns set to end, for numbers, read from the right. */
339
+ .num.end {
340
+ text-align: right;
341
+ }
342
+ /* Read-only rows have no action; the name runs to the edge. */
343
+ .static .row-wrap {
344
+ grid-template-columns: minmax(0, 1fr);
345
+ }
346
+ .static .action {
347
+ display: none;
348
+ }
349
+ </style>
@@ -0,0 +1,18 @@
1
+ <!-- Fields side by side in equal columns. -->
2
+ <script>
3
+ export let columns = 2;
4
+ </script>
5
+
6
+ <div class="grid" style:--columns={columns}>
7
+ <slot />
8
+ </div>
9
+
10
+ <style>
11
+ .grid {
12
+ display: grid;
13
+ /* minmax(0, 1fr), not 1fr: a bare 1fr track will not shrink below its
14
+ content, and a field is wider than this panel. */
15
+ grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
16
+ gap: var(--size-xxsmall);
17
+ }
18
+ </style>
@@ -98,6 +98,16 @@
98
98
  justify-content: end;
99
99
  }
100
100
 
101
+ /* A kit Text at the footer's edge sits 16px in: a button's own padding
102
+ insets its label, so buttons sit 8px in. */
103
+ .footer--split .footer__left > :global(.text:first-child) {
104
+ margin-left: var(--size-xxsmall);
105
+ }
106
+
107
+ .footer__right > :global(.text:last-child) {
108
+ margin-right: var(--size-xxsmall);
109
+ }
110
+
101
111
  .footer--full {
102
112
  display: flex;
103
113
  width: 100%;
@@ -9,6 +9,9 @@
9
9
 
10
10
  /** Remove bottom border */
11
11
  export let noBorder = false;
12
+
13
+ /** Heading level of the title: 1 for the plugin's own header, 2 or 3 for a bar inside a panel or modal */
14
+ export let level = 1;
12
15
  </script>
13
16
 
14
17
  <header
@@ -19,7 +22,9 @@
19
22
  <div class="header__left">
20
23
  <slot name="left" />
21
24
  {#if title}
22
- <h1 class="header__title">{title}</h1>
25
+ <svelte:element this={`h${level}`} class="header__title"
26
+ >{title}</svelte:element
27
+ >
23
28
  {/if}
24
29
  </div>
25
30
  <div class="header__center">
@@ -0,0 +1,35 @@
1
+ <!--
2
+ A ladder's sizes as neutral badges: outlined where the size is used,
3
+ disabled (the archived badge) where it isn't. Each badge's title is the
4
+ caller's, so each plugin words its own.
5
+ -->
6
+ <script>
7
+ import { Badge, Tooltip } from "figma-ui3-kit-svelte";
8
+
9
+ /** @type {{ value: number, used: boolean, title: string }[]} */
10
+ export let badges = [];
11
+ /** Names the group, e.g. "Ladder sizes". */
12
+ export let ariaLabel;
13
+ </script>
14
+
15
+ <div class="ladder" role="group" aria-label={ariaLabel}>
16
+ <!-- By index: an imported ladder can repeat a value. Tooltips, not
17
+ titles: a plugin's frame may not show a title. -->
18
+ {#each badges as badge, i (i)}
19
+ <Tooltip label={badge.title} direction="Top">
20
+ <Badge
21
+ variant={badge.used ? "default" : "archived"}
22
+ ariaLabel="{badge.value}px, {badge.used ? 'used' : 'unused'}"
23
+ >{badge.value}</Badge
24
+ >
25
+ </Tooltip>
26
+ {/each}
27
+ </div>
28
+
29
+ <style>
30
+ .ladder {
31
+ display: flex;
32
+ flex-wrap: wrap;
33
+ gap: 4px;
34
+ }
35
+ </style>
@@ -19,6 +19,7 @@
19
19
  * on:menuSelect={handleMenuAction}
20
20
  * >
21
21
  * <span>Additional info</span>
22
+ * <Button slot="actions" variant="secondary" on:click={fill}>Fill</Button>
22
23
  * </ListItem>
23
24
  */
24
25
 
@@ -70,33 +71,41 @@
70
71
  </script>
71
72
 
72
73
  <div class="list-item-wrapper {className}">
73
- <div
74
- class="list-item"
75
- class:active
76
- on:click={handleClick}
77
- on:keydown={(e) => {
78
- if (e.key === "Enter" || e.key === " ") {
79
- e.preventDefault();
80
- handleClick();
81
- }
82
- }}
83
- role="button"
84
- tabindex="0"
85
- aria-pressed={active}
86
- >
87
- <div class="list-item__content">
88
- <div class="list-item__title">{title}</div>
89
- {#if $$slots.default}
90
- <div class="list-item__meta">
91
- <slot />
92
- </div>
93
- {/if}
94
- {#if hasBadge && $$slots.badge}
95
- <div class="list-item__badge">
96
- <slot name="badge" />
97
- </div>
98
- {/if}
74
+ <!-- The actions sit in the card beside the clickable area, not inside it:
75
+ a button can't be nested in another button -->
76
+ <div class="list-item" class:active>
77
+ <div
78
+ class="list-item__main"
79
+ on:click={handleClick}
80
+ on:keydown={(e) => {
81
+ if (e.key === "Enter" || e.key === " ") {
82
+ e.preventDefault();
83
+ handleClick();
84
+ }
85
+ }}
86
+ role="button"
87
+ tabindex="0"
88
+ aria-pressed={active}
89
+ >
90
+ <div class="list-item__content">
91
+ <div class="list-item__title">{title}</div>
92
+ {#if $$slots.default}
93
+ <div class="list-item__meta">
94
+ <slot />
95
+ </div>
96
+ {/if}
97
+ {#if hasBadge && $$slots.badge}
98
+ <div class="list-item__badge">
99
+ <slot name="badge" />
100
+ </div>
101
+ {/if}
102
+ </div>
99
103
  </div>
104
+ {#if $$slots.actions}
105
+ <div class="list-item__actions">
106
+ <slot name="actions" />
107
+ </div>
108
+ {/if}
100
109
  </div>
101
110
 
102
111
  {#if menuItems.length > 0}
@@ -128,11 +137,9 @@
128
137
  flex: 1;
129
138
  display: flex;
130
139
  align-items: center;
131
- padding: var(--size-xxsmall);
132
140
  background: var(--figma-color-bg-secondary);
133
141
  border: 1px solid transparent;
134
142
  border-radius: var(--border-radius-medium);
135
- cursor: pointer;
136
143
  transition: border-color 0.15s ease;
137
144
  min-width: 0;
138
145
  }
@@ -146,6 +153,24 @@
146
153
  background: var(--figma-color-bg-brand-tertiary);
147
154
  }
148
155
 
156
+ .list-item__main {
157
+ flex: 1;
158
+ display: flex;
159
+ align-items: center;
160
+ padding: var(--size-xxsmall);
161
+ border-radius: inherit;
162
+ cursor: pointer;
163
+ min-width: 0;
164
+ }
165
+
166
+ /* The main area's own padding spaces them from the text */
167
+ .list-item__actions {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: var(--size-xxxsmall);
171
+ padding-right: var(--size-xxsmall);
172
+ }
173
+
149
174
  .list-item__content {
150
175
  flex: 1;
151
176
  display: flex;