figma-plugin-utilities 0.4.0 → 0.5.1
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 +41 -1
- package/README.md +134 -26
- package/package.json +27 -2
- package/src/components/CodeExportModal.svelte +98 -0
- package/src/components/ConfirmModal.svelte +66 -0
- package/src/components/DataTable.svelte +349 -0
- package/src/components/FieldGrid.svelte +18 -0
- package/src/components/Footer.svelte +10 -0
- package/src/components/Header.svelte +6 -1
- package/src/components/LadderBadges.svelte +35 -0
- package/src/components/ListItem.svelte +53 -28
- package/src/components/MappingChip.svelte +150 -0
- package/src/components/RampCurve.svelte +383 -0
- package/src/components/Section.svelte +49 -0
- package/src/components/SteppedField.svelte +52 -0
- package/src/components/index.js +9 -0
- package/src/index.js +19 -3
- package/src/lib/colors.ts +99 -0
- package/src/lib/confirm.ts +55 -0
- package/src/lib/errorHandling.js +0 -39
- package/src/lib/figma-frame-builders.ts +2 -2
- package/src/lib/figma-helpers.ts +103 -59
- package/src/lib/figma-variables.ts +151 -0
- package/src/lib/format.ts +19 -0
- package/src/lib/index.js +13 -4
- package/src/lib/scale.ts +147 -0
- package/src/lib/colors.js +0 -80
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
MappingChip: one side of a source → target row, as a filled 24px chip with no
|
|
3
|
+
border — Data Mapper's MappingButton, the Vitrine audit plugin's source and
|
|
4
|
+
Icon Swapper's source icon. It is a button: clicking it usually selects or
|
|
5
|
+
opens what it names.
|
|
6
|
+
|
|
7
|
+
A lead icon or chit hangs into the padding, as the kit's Dropdown does with
|
|
8
|
+
its own, so chips with and without one start their text a lead's width apart
|
|
9
|
+
rather than a lead plus the padding. The label truncates; `preview` follows
|
|
10
|
+
it after a dot, and `count` sits at the chip's end.
|
|
11
|
+
-->
|
|
12
|
+
<script>
|
|
13
|
+
import { createEventDispatcher } from "svelte";
|
|
14
|
+
import { Chit, Icon } from "figma-ui3-kit-svelte";
|
|
15
|
+
|
|
16
|
+
/** The chip's text */
|
|
17
|
+
export let label = "";
|
|
18
|
+
/** Secondary text after the label, as "label · preview" */
|
|
19
|
+
export let preview = "";
|
|
20
|
+
/** @type {number | string | null} a trailing count, such as how many layers the chip stands for */
|
|
21
|
+
export let count = null;
|
|
22
|
+
/** @type {any} a lead icon, from `figma-ui3-kit-svelte/icons` */
|
|
23
|
+
export let iconName = null;
|
|
24
|
+
/** @type {string | string[] | null} a lead chit, the kit Chit's `color`; wins over `iconName` */
|
|
25
|
+
export let chit = null;
|
|
26
|
+
/** @type {"default" | "secondary" | "component"} text and icon color: `component` for instances, `secondary` for a quieter side */
|
|
27
|
+
export let tone = "default";
|
|
28
|
+
/** Marks the chip as the chosen one, with the selection border */
|
|
29
|
+
export let selected = false;
|
|
30
|
+
export let disabled = false;
|
|
31
|
+
/** @type {string | undefined} */
|
|
32
|
+
export let title = undefined;
|
|
33
|
+
/** @type {string | undefined} */
|
|
34
|
+
export let ariaLabel = undefined;
|
|
35
|
+
/** @type {HTMLButtonElement | null} the button, for anchoring a menu or popover to it */
|
|
36
|
+
export let element = null;
|
|
37
|
+
|
|
38
|
+
let className = "";
|
|
39
|
+
export { className as class };
|
|
40
|
+
|
|
41
|
+
const dispatch = createEventDispatcher();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} tone
|
|
45
|
+
* @param {boolean} disabled
|
|
46
|
+
*/
|
|
47
|
+
function iconColorFor(tone, disabled) {
|
|
48
|
+
if (disabled) return "--figma-color-icon-disabled";
|
|
49
|
+
if (tone === "component") return "--figma-color-icon-component";
|
|
50
|
+
if (tone === "secondary") return "--figma-color-icon-secondary";
|
|
51
|
+
return "--figma-color-icon";
|
|
52
|
+
}
|
|
53
|
+
$: iconColor = iconColorFor(tone, disabled);
|
|
54
|
+
|
|
55
|
+
/** @param {MouseEvent} event */
|
|
56
|
+
function handleClick(event) {
|
|
57
|
+
if (!disabled) dispatch("click", event);
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<button
|
|
62
|
+
bind:this={element}
|
|
63
|
+
class="mapping-chip {tone} {className}"
|
|
64
|
+
class:selected
|
|
65
|
+
{disabled}
|
|
66
|
+
{title}
|
|
67
|
+
aria-label={ariaLabel}
|
|
68
|
+
on:click={handleClick}
|
|
69
|
+
>
|
|
70
|
+
<!-- Ahead of the lead, for a marker such as a refused write's "!". -->
|
|
71
|
+
<slot name="lead" />
|
|
72
|
+
{#if chit}
|
|
73
|
+
<span class="lead"><Chit color={chit} /></span>
|
|
74
|
+
{:else if iconName}
|
|
75
|
+
<span class="lead"><Icon {iconName} color={iconColor} /></span>
|
|
76
|
+
{/if}
|
|
77
|
+
<span class="text">
|
|
78
|
+
{label}{#if preview}<span class="preview"> · {preview}</span>{/if}
|
|
79
|
+
</span>
|
|
80
|
+
{#if count !== null && count !== ""}
|
|
81
|
+
<span class="count">{count}</span>
|
|
82
|
+
{/if}
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
<style>
|
|
86
|
+
.mapping-chip {
|
|
87
|
+
appearance: none;
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: var(--size-xxxsmall);
|
|
91
|
+
height: var(--size-small);
|
|
92
|
+
min-width: 0;
|
|
93
|
+
padding: 0 var(--size-xxsmall);
|
|
94
|
+
border: 0;
|
|
95
|
+
border-radius: var(--border-radius-medium);
|
|
96
|
+
background: var(--figma-color-bg-secondary);
|
|
97
|
+
font-family: var(--font-stack);
|
|
98
|
+
font-size: var(--body-medium-font-size);
|
|
99
|
+
font-weight: var(--body-medium-font-weight);
|
|
100
|
+
letter-spacing: var(--body-medium-letter-spacing);
|
|
101
|
+
line-height: var(--body-medium-line-height);
|
|
102
|
+
color: var(--figma-color-text);
|
|
103
|
+
text-align: left;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
user-select: none;
|
|
106
|
+
}
|
|
107
|
+
.mapping-chip.secondary {
|
|
108
|
+
color: var(--figma-color-text-secondary);
|
|
109
|
+
}
|
|
110
|
+
.mapping-chip.component {
|
|
111
|
+
color: var(--figma-color-text-component);
|
|
112
|
+
}
|
|
113
|
+
.mapping-chip:hover:not(:disabled) {
|
|
114
|
+
background: var(--figma-color-bg-tertiary);
|
|
115
|
+
}
|
|
116
|
+
.mapping-chip:focus-visible {
|
|
117
|
+
outline: 1px solid var(--figma-color-border-selected);
|
|
118
|
+
outline-offset: -1px;
|
|
119
|
+
}
|
|
120
|
+
.mapping-chip.selected {
|
|
121
|
+
box-shadow: inset 0 0 0 1px var(--figma-color-border-selected);
|
|
122
|
+
}
|
|
123
|
+
.mapping-chip:disabled {
|
|
124
|
+
color: var(--figma-color-text-disabled);
|
|
125
|
+
cursor: not-allowed;
|
|
126
|
+
}
|
|
127
|
+
/* The lead's 24px cell already carries the inset its glyph needs, so it
|
|
128
|
+
hangs into the padding, and the text follows it without a gap. */
|
|
129
|
+
.lead {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex: 0 0 auto;
|
|
132
|
+
margin-left: calc(-1 * var(--size-xxsmall));
|
|
133
|
+
margin-right: calc(-1 * var(--size-xxxsmall));
|
|
134
|
+
}
|
|
135
|
+
.text {
|
|
136
|
+
flex: 1;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
text-overflow: ellipsis;
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
}
|
|
142
|
+
.preview {
|
|
143
|
+
color: var(--figma-color-text-secondary);
|
|
144
|
+
}
|
|
145
|
+
.count {
|
|
146
|
+
flex: 0 0 auto;
|
|
147
|
+
color: var(--figma-color-text-tertiary);
|
|
148
|
+
font-variant-numeric: tabular-nums;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A ramp's quadratic Bézier at the breakpoint shown, with the other
|
|
3
|
+
breakpoints' curves faint behind it, from the ramp editors of Vitrine
|
|
4
|
+
Tools' Type and Spacing tabs. Everything up the y axis is in the caller's units — px along
|
|
5
|
+
a type ladder, rungs of a spacing one — so the caller passes the curves,
|
|
6
|
+
the grid lines, the dots and how a point snaps to a rung. At the smallest
|
|
7
|
+
and largest breakpoint the three control points are handles: bottom and
|
|
8
|
+
top move whole rungs, the bend moves anywhere and slides along x, the same
|
|
9
|
+
at every breakpoint. Those between are blends, only shown. Fires `change`
|
|
10
|
+
with a patch of the ramp, and `select` with a breakpoint's index.
|
|
11
|
+
-->
|
|
12
|
+
<script>
|
|
13
|
+
import { createEventDispatcher } from "svelte";
|
|
14
|
+
import { Button, Dropdown, Text } from "figma-ui3-kit-svelte";
|
|
15
|
+
import { clampPosition } from "../lib/scale";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The ends in rungs and the bends at the smallest (`Sm`) and largest
|
|
19
|
+
* (`Lg`) breakpoint, and where along x the bend sits.
|
|
20
|
+
* @type {{ bottomSm: number, topSm: number, bendSm: number, bottomLg: number, topLg: number, bendLg: number, bendPosition?: number }}
|
|
21
|
+
*/
|
|
22
|
+
export let ramp;
|
|
23
|
+
/** @type {[number, number, number][]} Each breakpoint's P0, P1 and P2. */
|
|
24
|
+
export let curves = [];
|
|
25
|
+
/** @type {[number, number]} The y range shown. */
|
|
26
|
+
export let span = [0, 1];
|
|
27
|
+
/** @type {{ key: string | number, y: number, label: string | number }[]} */
|
|
28
|
+
export let grid = [];
|
|
29
|
+
/** @type {{ key: string | number, x: number, y: number, marked?: boolean }[]} x from 0 to 1. */
|
|
30
|
+
export let dots = [];
|
|
31
|
+
/** @type {string[]} Breakpoint names. */
|
|
32
|
+
export let breakpoints = [];
|
|
33
|
+
/** Index of the breakpoint shown. */
|
|
34
|
+
export let selected = 0;
|
|
35
|
+
/** How many rungs the ends can take. */
|
|
36
|
+
export let rungCount = 2;
|
|
37
|
+
/** The rung nearest a y value, for dragging an end. */
|
|
38
|
+
export let rungAt = (y) => y;
|
|
39
|
+
/** An end's y value as its slider reads it, e.g. "16px". */
|
|
40
|
+
export let format = (y) => `${Math.round(y)}`;
|
|
41
|
+
/** Names the chart, e.g. "Heading ramp"; the breakpoint follows. */
|
|
42
|
+
export let ariaLabel;
|
|
43
|
+
export let bottomLabel = "First";
|
|
44
|
+
export let topLabel = "Last";
|
|
45
|
+
/** What runs along x, for the bend's value, e.g. "levels". */
|
|
46
|
+
export let along = "sets";
|
|
47
|
+
|
|
48
|
+
const dispatch = createEventDispatcher();
|
|
49
|
+
|
|
50
|
+
const W = 240;
|
|
51
|
+
const H = 180;
|
|
52
|
+
const PAD = { left: 28, right: 8, top: 8, bottom: 8 };
|
|
53
|
+
const plotW = W - PAD.left - PAD.right;
|
|
54
|
+
const plotH = H - PAD.top - PAD.bottom;
|
|
55
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
56
|
+
|
|
57
|
+
$: lastIndex = breakpoints.length - 1;
|
|
58
|
+
$: editable = selected === 0 || selected === lastIndex;
|
|
59
|
+
$: end = selected === 0 ? "Sm" : "Lg";
|
|
60
|
+
$: topRung = Math.max(1, rungCount - 1);
|
|
61
|
+
$: breakpointItems = breakpoints.map((name, b) => ({
|
|
62
|
+
label: name,
|
|
63
|
+
value: b,
|
|
64
|
+
}));
|
|
65
|
+
$: breakpointItem = breakpointItems.find((i) => i.value === selected) ?? null;
|
|
66
|
+
|
|
67
|
+
// While dragging, the axis holds still; rescaling under the pointer would
|
|
68
|
+
// make the handle run away from it.
|
|
69
|
+
let frozen = null;
|
|
70
|
+
$: [yMin, yMax] = frozen ?? span;
|
|
71
|
+
const X = (x) => PAD.left + x * plotW;
|
|
72
|
+
const toY = (v, lo, hi) => PAD.top + plotH - ((v - lo) / (hi - lo)) * plotH;
|
|
73
|
+
const fromY = (y, lo, hi) => lo + ((PAD.top + plotH - y) / plotH) * (hi - lo);
|
|
74
|
+
|
|
75
|
+
// Coordinates are worked out here, not in the markup: a helper called from
|
|
76
|
+
// the markup would not re-run when the ramp changes.
|
|
77
|
+
// Lines in view, labelled where there's room.
|
|
78
|
+
$: lines = grid
|
|
79
|
+
.map((g) => ({ ...g, cy: toY(g.y, yMin, yMax) }))
|
|
80
|
+
.filter((g) => g.y >= yMin && g.y <= yMax)
|
|
81
|
+
.reduce((acc, g) => {
|
|
82
|
+
const labelled = acc.filter((a) => a.label !== null);
|
|
83
|
+
const prev = labelled[labelled.length - 1];
|
|
84
|
+
acc.push({ ...g, label: !prev || prev.cy - g.cy >= 10 ? g.label : null });
|
|
85
|
+
return acc;
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
$: position = clampPosition(ramp.bendPosition ?? 0.5);
|
|
89
|
+
// A quadratic with its control point at (position, P1): SVG draws it as is.
|
|
90
|
+
function pathFor([p0, p1, p2], c, lo, hi) {
|
|
91
|
+
return `M${X(0)},${toY(p0, lo, hi)} Q${X(c)},${toY(p1, lo, hi)} ${X(1)},${toY(p2, lo, hi)}`;
|
|
92
|
+
}
|
|
93
|
+
$: paths = curves.map((p) => pathFor(p, position, yMin, yMax));
|
|
94
|
+
$: current = curves[selected] ?? [0, 0, 0];
|
|
95
|
+
$: polygon = [0, position, 1]
|
|
96
|
+
.map((x, i) => `${i ? "L" : "M"}${X(x)},${toY(current[i], yMin, yMax)}`)
|
|
97
|
+
.join(" ");
|
|
98
|
+
$: circles = dots.map((d) => ({
|
|
99
|
+
...d,
|
|
100
|
+
cx: X(d.x),
|
|
101
|
+
cy: toY(d.y, yMin, yMax),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
$: bend = end === "Sm" ? ramp.bendSm : ramp.bendLg;
|
|
105
|
+
$: handles = [
|
|
106
|
+
{ id: "bottom", x: 0, y: current[0], label: bottomLabel },
|
|
107
|
+
{ id: "bend", x: position, y: current[1], label: "Bend" },
|
|
108
|
+
{ id: "top", x: 1, y: current[2], label: topLabel },
|
|
109
|
+
].map((h) => ({
|
|
110
|
+
...h,
|
|
111
|
+
cx: X(h.x),
|
|
112
|
+
cy: toY(h.y, yMin, yMax),
|
|
113
|
+
valueNow: h.id === "bend" ? bend : ramp[`${h.id}${end}`],
|
|
114
|
+
valueMin: h.id === "bend" ? -0.5 : 0,
|
|
115
|
+
valueMax: h.id === "bend" ? 1.5 : topRung,
|
|
116
|
+
valueText:
|
|
117
|
+
h.id === "bend"
|
|
118
|
+
? `bend ${bend.toFixed(2)}, at ${position.toFixed(2)} along the ${along}`
|
|
119
|
+
: format(h.y),
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
function setBend(value) {
|
|
123
|
+
const rounded = Math.round(value * 100) / 100;
|
|
124
|
+
dispatch("change", { [`bend${end}`]: clamp(rounded, -0.5, 1.5) });
|
|
125
|
+
}
|
|
126
|
+
function setPosition(value) {
|
|
127
|
+
dispatch("change", {
|
|
128
|
+
bendPosition: clampPosition(Math.round(value * 100) / 100),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function setEnd(handle, rung) {
|
|
132
|
+
dispatch("change", {
|
|
133
|
+
[`${handle}${end}`]: clamp(Math.round(rung), 0, topRung),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let svg;
|
|
138
|
+
let dragging = null;
|
|
139
|
+
function yAt(event) {
|
|
140
|
+
const box = svg.getBoundingClientRect();
|
|
141
|
+
const y = ((event.clientY - box.top) / box.height) * H;
|
|
142
|
+
return fromY(y, yMin, yMax);
|
|
143
|
+
}
|
|
144
|
+
// Where along x the pointer is, 0…1.
|
|
145
|
+
function xAt(event) {
|
|
146
|
+
const box = svg.getBoundingClientRect();
|
|
147
|
+
const x = ((event.clientX - box.left) / box.width) * W;
|
|
148
|
+
return (x - PAD.left) / plotW;
|
|
149
|
+
}
|
|
150
|
+
function handleDown(event, handle) {
|
|
151
|
+
if (!editable) return;
|
|
152
|
+
dragging = handle;
|
|
153
|
+
frozen = [yMin, yMax];
|
|
154
|
+
// Text selection would start as the drag leaves the chart.
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
157
|
+
event.currentTarget.focus();
|
|
158
|
+
}
|
|
159
|
+
// The drag is followed on the window, not by pointer capture alone, which
|
|
160
|
+
// doesn't always take in a plugin's iframe: the handle keeps moving once
|
|
161
|
+
// the pointer leaves the chart.
|
|
162
|
+
function handleMove(event) {
|
|
163
|
+
if (!dragging) return;
|
|
164
|
+
// Let go outside the plugin's window, where the release went unheard.
|
|
165
|
+
if (event.buttons === 0) return handleUp();
|
|
166
|
+
const y = yAt(event);
|
|
167
|
+
if (dragging === "bend") {
|
|
168
|
+
const range = current[2] - current[0];
|
|
169
|
+
if (range !== 0) setBend((y - current[0]) / range);
|
|
170
|
+
setPosition(xAt(event));
|
|
171
|
+
} else {
|
|
172
|
+
setEnd(dragging, rungAt(y));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function handleUp() {
|
|
176
|
+
if (!dragging) return;
|
|
177
|
+
dragging = null;
|
|
178
|
+
frozen = null;
|
|
179
|
+
}
|
|
180
|
+
function handleKey(event, handle) {
|
|
181
|
+
if (
|
|
182
|
+
handle === "bend" &&
|
|
183
|
+
(event.key === "ArrowLeft" || event.key === "ArrowRight")
|
|
184
|
+
) {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
setPosition(position + (event.key === "ArrowRight" ? 0.02 : -0.02));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
const up = event.key === "ArrowUp" ? 1 : -1;
|
|
192
|
+
if (handle === "bend") setBend(bend + up * 0.02);
|
|
193
|
+
else setEnd(handle, ramp[`${handle}${end}`] + up);
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<svelte:window
|
|
198
|
+
on:pointermove={handleMove}
|
|
199
|
+
on:pointerup={handleUp}
|
|
200
|
+
on:pointercancel={handleUp}
|
|
201
|
+
/>
|
|
202
|
+
|
|
203
|
+
<div class="ramp-curve">
|
|
204
|
+
<Dropdown
|
|
205
|
+
menuItems={breakpointItems}
|
|
206
|
+
value={breakpointItem}
|
|
207
|
+
on:change={(e) => dispatch("select", e.detail.value)}
|
|
208
|
+
ariaLabel="Breakpoint shown"
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
{#if !editable}
|
|
212
|
+
<Text variant="body-small" color="--figma-color-text-secondary"
|
|
213
|
+
>Calculated from the endpoints.</Text
|
|
214
|
+
>
|
|
215
|
+
<div class="endpoint-actions">
|
|
216
|
+
<Button variant="link" on:click={() => dispatch("select", 0)}
|
|
217
|
+
>Edit {breakpoints[0]}</Button
|
|
218
|
+
>
|
|
219
|
+
<Button variant="link" on:click={() => dispatch("select", lastIndex)}
|
|
220
|
+
>Edit {breakpoints[lastIndex]}</Button
|
|
221
|
+
>
|
|
222
|
+
</div>
|
|
223
|
+
{/if}
|
|
224
|
+
<svg
|
|
225
|
+
bind:this={svg}
|
|
226
|
+
viewBox="0 0 {W} {H}"
|
|
227
|
+
role="group"
|
|
228
|
+
aria-label="{ariaLabel} at {breakpoints[selected]}"
|
|
229
|
+
>
|
|
230
|
+
{#each lines as line (line.key)}
|
|
231
|
+
<line
|
|
232
|
+
class="grid"
|
|
233
|
+
x1={PAD.left}
|
|
234
|
+
x2={W - PAD.right}
|
|
235
|
+
y1={line.cy}
|
|
236
|
+
y2={line.cy}
|
|
237
|
+
/>
|
|
238
|
+
{#if line.label !== null}
|
|
239
|
+
<text class="axis" x={PAD.left - 4} y={line.cy + 3}>{line.label}</text>
|
|
240
|
+
{/if}
|
|
241
|
+
{/each}
|
|
242
|
+
|
|
243
|
+
{#each paths as d, b (b)}
|
|
244
|
+
{#if b !== selected}
|
|
245
|
+
<path class="curve other" {d} />
|
|
246
|
+
{/if}
|
|
247
|
+
{/each}
|
|
248
|
+
<path class="polygon" d={polygon} />
|
|
249
|
+
<path class="curve" d={paths[selected]} />
|
|
250
|
+
|
|
251
|
+
{#each circles as dot (dot.key)}
|
|
252
|
+
<circle
|
|
253
|
+
class="dot"
|
|
254
|
+
class:marked={dot.marked}
|
|
255
|
+
cx={dot.cx}
|
|
256
|
+
cy={dot.cy}
|
|
257
|
+
r={dot.marked ? 3 : 2.5}
|
|
258
|
+
/>
|
|
259
|
+
{/each}
|
|
260
|
+
|
|
261
|
+
<!-- Handles as Figma draws a path's vertices: white with a blue ring,
|
|
262
|
+
solid blue while dragged or focused. A wider, invisible circle
|
|
263
|
+
takes the pointer. -->
|
|
264
|
+
{#if editable}
|
|
265
|
+
{#each handles as h (h.id)}
|
|
266
|
+
<g
|
|
267
|
+
class="handle"
|
|
268
|
+
class:active={dragging === h.id}
|
|
269
|
+
class:bend={h.id === "bend"}
|
|
270
|
+
transform="translate({h.cx} {h.cy})"
|
|
271
|
+
tabindex="0"
|
|
272
|
+
role="slider"
|
|
273
|
+
aria-label="{h.label} at {breakpoints[selected]}"
|
|
274
|
+
aria-valuenow={h.valueNow}
|
|
275
|
+
aria-valuemin={h.valueMin}
|
|
276
|
+
aria-valuemax={h.valueMax}
|
|
277
|
+
aria-valuetext={h.valueText}
|
|
278
|
+
on:pointerdown={(e) => handleDown(e, h.id)}
|
|
279
|
+
on:keydown={(e) => handleKey(e, h.id)}
|
|
280
|
+
>
|
|
281
|
+
<circle class="hit" r="8" />
|
|
282
|
+
<circle class="knob" r={h.id === "bend" ? 3 : 3.5} />
|
|
283
|
+
</g>
|
|
284
|
+
{/each}
|
|
285
|
+
{/if}
|
|
286
|
+
</svg>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<style>
|
|
290
|
+
.ramp-curve {
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-direction: column;
|
|
293
|
+
gap: var(--size-xxsmall);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.endpoint-actions {
|
|
297
|
+
display: flex;
|
|
298
|
+
gap: var(--size-xxsmall);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
svg {
|
|
302
|
+
display: block;
|
|
303
|
+
width: 100%;
|
|
304
|
+
height: auto;
|
|
305
|
+
touch-action: none;
|
|
306
|
+
user-select: none;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.grid {
|
|
310
|
+
stroke: var(--figma-color-border);
|
|
311
|
+
stroke-width: 0.5;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.axis {
|
|
315
|
+
fill: var(--figma-color-text-secondary);
|
|
316
|
+
font-size: 8px;
|
|
317
|
+
font-family: var(--font-stack);
|
|
318
|
+
/* Inter's tabular lining figures, Figma's "Monospace uppercase/lining". */
|
|
319
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
320
|
+
text-anchor: end;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.curve {
|
|
324
|
+
fill: none;
|
|
325
|
+
stroke: var(--figma-color-text);
|
|
326
|
+
stroke-width: 1.5;
|
|
327
|
+
stroke-linecap: round;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.curve.other {
|
|
331
|
+
stroke: var(--figma-color-text-tertiary, var(--figma-color-text-secondary));
|
|
332
|
+
stroke-width: 1;
|
|
333
|
+
opacity: 0.5;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* The handle arms, as Figma draws a Bézier's tangents. */
|
|
337
|
+
.polygon {
|
|
338
|
+
fill: none;
|
|
339
|
+
stroke: var(--figma-color-border-selected);
|
|
340
|
+
stroke-width: 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.dot {
|
|
344
|
+
fill: var(--figma-color-bg);
|
|
345
|
+
stroke: var(--figma-color-text-secondary);
|
|
346
|
+
stroke-width: 1;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.dot.marked {
|
|
350
|
+
fill: var(--figma-color-bg-brand);
|
|
351
|
+
stroke: var(--figma-color-bg-brand);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.handle {
|
|
355
|
+
cursor: ns-resize;
|
|
356
|
+
outline: none;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.handle.bend {
|
|
360
|
+
cursor: move;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.hit {
|
|
364
|
+
fill: transparent;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.knob {
|
|
368
|
+
fill: var(--figma-color-icon-onbrand, #fff);
|
|
369
|
+
stroke: var(--figma-color-border-selected);
|
|
370
|
+
stroke-width: 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.handle:hover .knob {
|
|
374
|
+
stroke-width: 1.5;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.handle.active .knob,
|
|
378
|
+
.handle:focus-visible .knob {
|
|
379
|
+
fill: var(--figma-color-bg-brand);
|
|
380
|
+
stroke: var(--figma-color-icon-onbrand, #fff);
|
|
381
|
+
stroke-width: 1;
|
|
382
|
+
}
|
|
383
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Section: a titled group of fields, as in Figma's panels — a fieldset headed
|
|
3
|
+
by Header, the title on its left and icon buttons at its right (the
|
|
4
|
+
`actions` slot). It pads its own content, so its parent must not: the
|
|
5
|
+
Header runs edge to edge. Rule sections apart with an <hr>.
|
|
6
|
+
-->
|
|
7
|
+
<script>
|
|
8
|
+
import Header from "./Header.svelte";
|
|
9
|
+
|
|
10
|
+
export let title;
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<fieldset class="section" aria-label={title}>
|
|
14
|
+
<Header {title} noBorder>
|
|
15
|
+
<svelte:fragment slot="right">
|
|
16
|
+
<slot name="actions" />
|
|
17
|
+
</svelte:fragment>
|
|
18
|
+
</Header>
|
|
19
|
+
<div class="body">
|
|
20
|
+
<slot />
|
|
21
|
+
</div>
|
|
22
|
+
</fieldset>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.section {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
border: none;
|
|
29
|
+
padding: 0;
|
|
30
|
+
margin: 0;
|
|
31
|
+
/* A fieldset is at least as wide as its content by default, so a
|
|
32
|
+
dropdown's long label would widen it past its container instead of
|
|
33
|
+
truncating. */
|
|
34
|
+
min-inline-size: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* The Header's own title is regular; a section's is strong, as in Figma's
|
|
38
|
+
panels. */
|
|
39
|
+
.section :global(.header__title) {
|
|
40
|
+
font-weight: var(--font-weight-strong);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.body {
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
gap: var(--size-xxsmall);
|
|
47
|
+
padding: 0 var(--size-xsmall) var(--size-xsmall);
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A field with − and + buttons after it, as one grid cell. The buttons fire
|
|
3
|
+
`step` with -1 or 1; what a step means is the caller's.
|
|
4
|
+
-->
|
|
5
|
+
<script>
|
|
6
|
+
import { createEventDispatcher } from "svelte";
|
|
7
|
+
import { IconButton } from "figma-ui3-kit-svelte";
|
|
8
|
+
import { IconMinus, IconPlus } from "figma-ui3-kit-svelte/icons";
|
|
9
|
+
|
|
10
|
+
/** The − button's label, e.g. "Step body down". */
|
|
11
|
+
export let downLabel;
|
|
12
|
+
/** The + button's label, e.g. "Step body up". */
|
|
13
|
+
export let upLabel;
|
|
14
|
+
|
|
15
|
+
const dispatch = createEventDispatcher();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<div class="stepped">
|
|
19
|
+
<div class="grow">
|
|
20
|
+
<slot />
|
|
21
|
+
</div>
|
|
22
|
+
<div class="steppers">
|
|
23
|
+
<IconButton
|
|
24
|
+
iconName={IconMinus}
|
|
25
|
+
ariaLabel={downLabel}
|
|
26
|
+
on:click={() => dispatch("step", -1)}
|
|
27
|
+
/>
|
|
28
|
+
<IconButton
|
|
29
|
+
iconName={IconPlus}
|
|
30
|
+
ariaLabel={upLabel}
|
|
31
|
+
on:click={() => dispatch("step", 1)}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
.stepped {
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: var(--size-xxsmall);
|
|
40
|
+
align-items: end;
|
|
41
|
+
min-width: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.grow {
|
|
45
|
+
flex: 1;
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.steppers {
|
|
50
|
+
display: flex;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
package/src/components/index.js
CHANGED
|
@@ -6,5 +6,14 @@ export { default as Footer } from "./Footer.svelte";
|
|
|
6
6
|
export { default as ListItem } from "./ListItem.svelte";
|
|
7
7
|
export { default as LoadingState } from "./LoadingState.svelte";
|
|
8
8
|
export { default as PluginLayout } from "./PluginLayout.svelte";
|
|
9
|
+
export { default as Section } from "./Section.svelte";
|
|
9
10
|
export { default as StatusBar } from "./StatusBar.svelte";
|
|
11
|
+
export { default as DataTable } from "./DataTable.svelte";
|
|
10
12
|
export { default as Header } from "./Header.svelte";
|
|
13
|
+
export { default as CodeExportModal } from "./CodeExportModal.svelte";
|
|
14
|
+
export { default as FieldGrid } from "./FieldGrid.svelte";
|
|
15
|
+
export { default as LadderBadges } from "./LadderBadges.svelte";
|
|
16
|
+
export { default as SteppedField } from "./SteppedField.svelte";
|
|
17
|
+
export { default as RampCurve } from "./RampCurve.svelte";
|
|
18
|
+
export { default as MappingChip } from "./MappingChip.svelte";
|
|
19
|
+
export { default as ConfirmModal } from "./ConfirmModal.svelte";
|
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";
|