@rogieking/figui3 6.20.2 → 6.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.20.2",
3
+ "version": "6.21.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",
@@ -19,6 +19,15 @@
19
19
  "./fig-lab.css": "./dist/fig-lab.css",
20
20
  "./base.css": "./dist/base.css",
21
21
  "./components.css": "./dist/components.css",
22
+ "./propskit": {
23
+ "types": "./propskit.d.ts",
24
+ "default": "./dist/propskit.js"
25
+ },
26
+ "./propskit.js": {
27
+ "types": "./propskit.d.ts",
28
+ "default": "./dist/propskit.js"
29
+ },
30
+ "./propskit.css": "./dist/propskit.css",
22
31
  "./src/fig.js": "./fig.js",
23
32
  "./src/fig-layer.js": "./fig-layer.js",
24
33
  "./src/fig-editor.js": "./fig-editor.js",
@@ -35,6 +44,9 @@
35
44
  "fig-layer.js",
36
45
  "fig-editor.js",
37
46
  "fig-lab.js",
47
+ "propskit.js",
48
+ "propskit-core.js",
49
+ "propskit.d.ts",
38
50
  "fig.css",
39
51
  "fig-layer.css",
40
52
  "fig-editor.css",
@@ -48,12 +60,18 @@
48
60
  "LICENSE"
49
61
  ],
50
62
  "sideEffects": [
51
- "*.css"
63
+ "*.css",
64
+ "./propskit.js",
65
+ "./dist/propskit.js",
66
+ "./fig.js",
67
+ "./fig-lab.js",
68
+ "./fig-editor.js"
52
69
  ],
53
70
  "scripts": {
54
71
  "dev": "bun --hot server.ts",
55
- "build": "bun build fig.js --minify --outdir dist && bun build fig-layer.js --minify --outdir dist && bun build fig-editor.js --minify --outdir dist --external ./fig.js && bun build fig-lab.js --minify --outdir dist --external ./fig.js --external ./fig-editor.js && npm run build:css",
72
+ "build": "bun build fig.js --minify --outdir dist && bun build fig-layer.js --minify --outdir dist && bun build fig-editor.js --minify --outdir dist --external ./fig.js && bun build fig-lab.js --minify --outdir dist --external ./fig.js --external ./fig-editor.js && bun build propskit.js --minify --outdir dist && npm run build:css && npm run build:propskit-css",
56
73
  "build:css": "node scripts/build-css.mjs",
74
+ "build:propskit-css": "node scripts/build-propskit-css.mjs",
57
75
  "dev:playground": "node playground/dev.mjs",
58
76
  "build:playground": "cd playground && npm run build",
59
77
  "test": "npm run test:components",
@@ -107,6 +125,17 @@
107
125
  "playwright": "^1.58.2"
108
126
  },
109
127
  "peerDependencies": {
110
- "typescript": "^5.0.0"
128
+ "typescript": "^5.0.0",
129
+ "react": ">=18",
130
+ "vue": ">=3.3",
131
+ "svelte": ">=5",
132
+ "solid-js": ">=1.8"
133
+ },
134
+ "peerDependenciesMeta": {
135
+ "react": { "optional": true },
136
+ "vue": { "optional": true },
137
+ "svelte": { "optional": true },
138
+ "solid-js": { "optional": true },
139
+ "typescript": { "optional": true }
111
140
  }
112
141
  }
@@ -0,0 +1,556 @@
1
+ /**
2
+ * PropsKit core — config store, theme, and DOM renderer.
3
+ * Framework-agnostic. Bundled into propskit.js.
4
+ */
5
+
6
+ export const PROPSKIT_SCOPE_ROOT_CLASS = "figui-root";
7
+ export const PROPSKIT_OVERLAY_ROOT_ATTR = "data-figui-overlay-root";
8
+
9
+ /** @typedef {'system' | 'light' | 'dark'} PropsKitTheme */
10
+
11
+ /**
12
+ * @param {Element} el
13
+ * @param {PropsKitTheme} [theme]
14
+ */
15
+ export function applyFiguiTheme(el, theme = "system") {
16
+ if (!el) return;
17
+ const normalized =
18
+ theme === "light" || theme === "dark" || theme === "system"
19
+ ? theme
20
+ : "system";
21
+ el.setAttribute("theme", normalized);
22
+ el.classList.add(PROPSKIT_SCOPE_ROOT_CLASS);
23
+ el.classList.toggle("figma-light", normalized === "light");
24
+ el.classList.toggle("figma-dark", normalized === "dark");
25
+ if (normalized === "system") {
26
+ el.style.setProperty("color-scheme", "light dark");
27
+ } else {
28
+ el.style.setProperty("color-scheme", normalized);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Sync theme from a panel root onto the document overlay portal.
34
+ * @param {Element | null} [source]
35
+ */
36
+ export function syncOverlayTheme(source) {
37
+ if (typeof document === "undefined" || !document.body) return null;
38
+ const attr = PROPSKIT_OVERLAY_ROOT_ATTR;
39
+ let root = document.body.querySelector(`:scope > [${attr}]`);
40
+ if (!root) {
41
+ root = document.createElement("div");
42
+ root.setAttribute(attr, "");
43
+ document.body.append(root);
44
+ }
45
+
46
+ const panel =
47
+ source?.closest?.(`.${PROPSKIT_SCOPE_ROOT_CLASS}`) ??
48
+ document.querySelector(`.${PROPSKIT_SCOPE_ROOT_CLASS}`);
49
+
50
+ if (!panel) return root;
51
+
52
+ const themeAttr = panel.getAttribute("theme");
53
+ const theme =
54
+ themeAttr === "light" || themeAttr === "dark" || themeAttr === "system"
55
+ ? themeAttr
56
+ : panel.classList.contains("figma-dark")
57
+ ? "dark"
58
+ : panel.classList.contains("figma-light")
59
+ ? "light"
60
+ : "system";
61
+
62
+ applyFiguiTheme(root, theme);
63
+ // Overlay root is not the panel; keep portal class semantics without forcing layout.
64
+ root.classList.remove(PROPSKIT_SCOPE_ROOT_CLASS);
65
+ root.setAttribute(attr, "");
66
+ if (theme === "light") root.classList.add("figma-light");
67
+ if (theme === "dark") root.classList.add("figma-dark");
68
+ return root;
69
+ }
70
+
71
+ function isPlainObject(value) {
72
+ return (
73
+ value !== null &&
74
+ typeof value === "object" &&
75
+ !Array.isArray(value) &&
76
+ Object.getPrototypeOf(value) === Object.prototype
77
+ );
78
+ }
79
+
80
+ function isHexColor(value) {
81
+ return typeof value === "string" && /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);
82
+ }
83
+
84
+ /**
85
+ * @param {string} key
86
+ * @param {unknown} value
87
+ * @returns {{ kind: string, key: string, label: string, [k: string]: unknown }}
88
+ */
89
+ export function inferControl(key, value) {
90
+ const label = key
91
+ .replace(/([A-Z])/g, " $1")
92
+ .replace(/[_-]+/g, " ")
93
+ .replace(/^\w/, (c) => c.toUpperCase())
94
+ .trim();
95
+
96
+ if (Array.isArray(value) && value.length >= 3 && value.every((n) => typeof n === "number")) {
97
+ const [def, min, max, step] = value;
98
+ return {
99
+ kind: "slider",
100
+ key,
101
+ label,
102
+ default: def,
103
+ min,
104
+ max,
105
+ step: step ?? (max - min <= 1 ? 0.01 : 1),
106
+ };
107
+ }
108
+
109
+ if (isPlainObject(value) && typeof value.type === "string") {
110
+ const type = value.type;
111
+ if (type === "select") {
112
+ return {
113
+ kind: "select",
114
+ key,
115
+ label,
116
+ options: value.options ?? [],
117
+ default: value.default ?? value.options?.[0]?.value ?? value.options?.[0] ?? "",
118
+ };
119
+ }
120
+ if (type === "color") {
121
+ return { kind: "color", key, label, default: value.default ?? "#000000" };
122
+ }
123
+ if (type === "text") {
124
+ return {
125
+ kind: "text",
126
+ key,
127
+ label,
128
+ default: value.default ?? "",
129
+ placeholder: value.placeholder,
130
+ };
131
+ }
132
+ if (type === "easing" || type === "spring") {
133
+ return {
134
+ kind: "easing",
135
+ key,
136
+ label,
137
+ default: value.value ?? value.ease ?? value.default ?? [0.4, 0, 0.2, 1],
138
+ spring: type === "spring" ? value : undefined,
139
+ };
140
+ }
141
+ if (type === "action") {
142
+ return { kind: "action", key, label };
143
+ }
144
+ }
145
+
146
+ if (isPlainObject(value)) {
147
+ const { _collapsed, ...rest } = value;
148
+ return {
149
+ kind: "folder",
150
+ key,
151
+ label,
152
+ collapsed: Boolean(_collapsed),
153
+ children: Object.entries(rest).map(([k, v]) => inferControl(k, v)),
154
+ };
155
+ }
156
+
157
+ if (typeof value === "boolean") {
158
+ return { kind: "switch", key, label, default: value };
159
+ }
160
+
161
+ if (typeof value === "number") {
162
+ const abs = Math.abs(value) || 1;
163
+ const max = abs <= 1 ? 1 : abs * 2;
164
+ const min = abs <= 1 ? 0 : 0;
165
+ return {
166
+ kind: "slider",
167
+ key,
168
+ label,
169
+ default: value,
170
+ min,
171
+ max,
172
+ step: abs <= 1 ? 0.01 : 1,
173
+ };
174
+ }
175
+
176
+ if (isHexColor(value)) {
177
+ return { kind: "color", key, label, default: value };
178
+ }
179
+
180
+ if (typeof value === "string") {
181
+ return { kind: "text", key, label, default: value };
182
+ }
183
+
184
+ return { kind: "text", key, label, default: String(value ?? "") };
185
+ }
186
+
187
+ /**
188
+ * @param {Record<string, unknown>} config
189
+ */
190
+ export function parseConfig(config) {
191
+ return Object.entries(config ?? {}).map(([key, value]) => inferControl(key, value));
192
+ }
193
+
194
+ function optionValue(option) {
195
+ if (option && typeof option === "object") return String(option.value ?? "");
196
+ return String(option ?? "");
197
+ }
198
+
199
+ function optionLabel(option) {
200
+ if (option && typeof option === "object") {
201
+ return String(option.label ?? option.value ?? "");
202
+ }
203
+ return String(option ?? "");
204
+ }
205
+
206
+ function getPath(obj, path) {
207
+ return path.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
208
+ }
209
+
210
+ function setPath(obj, path, value) {
211
+ const parts = path.split(".");
212
+ let cursor = obj;
213
+ for (let i = 0; i < parts.length - 1; i++) {
214
+ const key = parts[i];
215
+ if (!isPlainObject(cursor[key])) cursor[key] = {};
216
+ cursor = cursor[key];
217
+ }
218
+ cursor[parts[parts.length - 1]] = value;
219
+ }
220
+
221
+ function defaultsFromDescriptors(descriptors, target = {}) {
222
+ for (const d of descriptors) {
223
+ if (d.kind === "folder") {
224
+ target[d.key] = {};
225
+ defaultsFromDescriptors(d.children, target[d.key]);
226
+ } else if (d.kind !== "action") {
227
+ target[d.key] = d.default;
228
+ }
229
+ }
230
+ return target;
231
+ }
232
+
233
+ function cloneValues(values) {
234
+ return structuredClone(values);
235
+ }
236
+
237
+ /**
238
+ * @param {ReturnType<typeof inferControl>} descriptor
239
+ * @param {string} path
240
+ * @param {Record<string, unknown>} values
241
+ * @param {(type: string, path: string, value?: unknown) => void} emit
242
+ */
243
+ function renderControl(descriptor, path, values, emit) {
244
+ if (descriptor.kind === "folder") {
245
+ const group = document.createElement("propskit-group");
246
+ group.setAttribute("name", descriptor.label);
247
+ if (!descriptor.collapsed) group.setAttribute("open", "true");
248
+ else group.setAttribute("open", "false");
249
+ for (const child of descriptor.children) {
250
+ group.append(
251
+ renderControl(child, path ? `${path}.${child.key}` : child.key, values, emit),
252
+ );
253
+ }
254
+ return group;
255
+ }
256
+
257
+ if (descriptor.kind === "action") {
258
+ const button = document.createElement("fig-button");
259
+ button.textContent = descriptor.label;
260
+ button.addEventListener("click", () => emit("action", path || descriptor.key));
261
+ return button;
262
+ }
263
+
264
+ if (descriptor.kind === "slider") {
265
+ const el = document.createElement("propskit-slider");
266
+ el.setAttribute("label", descriptor.label);
267
+ el.setAttribute("min", String(descriptor.min));
268
+ el.setAttribute("max", String(descriptor.max));
269
+ el.setAttribute("step", String(descriptor.step));
270
+ el.setAttribute("value", String(getPath(values, path) ?? descriptor.default));
271
+ el.setAttribute("full", "");
272
+ const forward = (event) => {
273
+ const detail =
274
+ event instanceof CustomEvent && event.detail !== undefined
275
+ ? event.detail
276
+ : el.value;
277
+ const next = typeof detail === "number" ? detail : Number(detail);
278
+ emit("change", path, Number.isFinite(next) ? next : detail);
279
+ };
280
+ el.addEventListener("input", forward);
281
+ el.addEventListener("change", forward);
282
+ return el;
283
+ }
284
+
285
+ if (descriptor.kind === "switch") {
286
+ const el = document.createElement("propskit-switch");
287
+ el.setAttribute("label", descriptor.label);
288
+ if (getPath(values, path) ?? descriptor.default) el.setAttribute("checked", "");
289
+ const forward = (event) => {
290
+ const detail =
291
+ event instanceof CustomEvent && event.detail !== undefined
292
+ ? event.detail
293
+ : null;
294
+ const checked =
295
+ detail && typeof detail === "object" && "checked" in detail
296
+ ? Boolean(detail.checked)
297
+ : el.hasAttribute("checked");
298
+ emit("change", path, checked);
299
+ };
300
+ el.addEventListener("input", forward);
301
+ el.addEventListener("change", forward);
302
+ return el;
303
+ }
304
+
305
+ if (descriptor.kind === "color") {
306
+ const el = document.createElement("propskit-color");
307
+ el.setAttribute("label", descriptor.label);
308
+ el.setAttribute("value", String(getPath(values, path) ?? descriptor.default));
309
+ const forward = (event) => {
310
+ const detail =
311
+ event instanceof CustomEvent && event.detail !== undefined
312
+ ? event.detail
313
+ : el.value;
314
+ emit("change", path, detail);
315
+ };
316
+ el.addEventListener("input", forward);
317
+ el.addEventListener("change", forward);
318
+ return el;
319
+ }
320
+
321
+ if (descriptor.kind === "text") {
322
+ const el = document.createElement("propskit-text");
323
+ el.setAttribute("label", descriptor.label);
324
+ el.setAttribute("value", String(getPath(values, path) ?? descriptor.default ?? ""));
325
+ if (descriptor.placeholder) el.setAttribute("placeholder", descriptor.placeholder);
326
+ const forward = (event) => {
327
+ const detail =
328
+ event instanceof CustomEvent && event.detail !== undefined
329
+ ? event.detail
330
+ : el.value;
331
+ emit("change", path, detail);
332
+ };
333
+ el.addEventListener("input", forward);
334
+ el.addEventListener("change", forward);
335
+ return el;
336
+ }
337
+
338
+ if (descriptor.kind === "select") {
339
+ const el = document.createElement("propskit-select");
340
+ el.setAttribute("label", descriptor.label);
341
+ const options = (descriptor.options ?? [])
342
+ .map((opt) => optionLabel(opt))
343
+ .join(",");
344
+ el.setAttribute("options", options);
345
+ const current = getPath(values, path) ?? descriptor.default;
346
+ el.setAttribute("value", String(current ?? ""));
347
+ const forward = (event) => {
348
+ const detail =
349
+ event instanceof CustomEvent && event.detail !== undefined
350
+ ? event.detail
351
+ : el.value;
352
+ const raw = typeof detail === "object" && detail && "value" in detail
353
+ ? detail.value
354
+ : detail;
355
+ const match = (descriptor.options ?? []).find(
356
+ (opt) => optionLabel(opt) === String(raw) || optionValue(opt) === String(raw),
357
+ );
358
+ emit("change", path, match ? optionValue(match) || optionLabel(match) : raw);
359
+ };
360
+ el.addEventListener("input", forward);
361
+ el.addEventListener("change", forward);
362
+ return el;
363
+ }
364
+
365
+ if (descriptor.kind === "easing") {
366
+ const field = document.createElement("fig-field");
367
+ field.setAttribute("direction", "horizontal");
368
+ const label = document.createElement("label");
369
+ label.textContent = descriptor.label;
370
+ const curve = document.createElement("fig-easing-curve");
371
+ const current = getPath(values, path) ?? descriptor.default;
372
+ if (Array.isArray(current)) {
373
+ curve.setAttribute("value", current.join(","));
374
+ }
375
+ field.append(label, curve);
376
+ const forward = (event) => {
377
+ const detail =
378
+ event instanceof CustomEvent && event.detail !== undefined
379
+ ? event.detail
380
+ : null;
381
+ emit(
382
+ "change",
383
+ path,
384
+ detail && typeof detail === "object" && "value" in detail
385
+ ? detail.value
386
+ : detail,
387
+ );
388
+ };
389
+ curve.addEventListener("input", forward);
390
+ curve.addEventListener("change", forward);
391
+ return field;
392
+ }
393
+
394
+ const fallback = document.createElement("propskit-text");
395
+ fallback.setAttribute("label", descriptor.label);
396
+ fallback.setAttribute("value", String(getPath(values, path) ?? ""));
397
+ return fallback;
398
+ }
399
+
400
+ /**
401
+ * @param {ParentNode} target
402
+ * @param {string} name
403
+ * @param {Record<string, unknown>} config
404
+ * @param {{
405
+ * theme?: PropsKitTheme,
406
+ * onChange?: (path: string, value: unknown, values: Record<string, unknown>) => void,
407
+ * onAction?: (name: string) => void,
408
+ * scoped?: boolean,
409
+ * }} [options]
410
+ */
411
+ export function createPropsKit(target, name, config, options = {}) {
412
+ if (
413
+ !target ||
414
+ (!(target instanceof Element) && !(target instanceof DocumentFragment))
415
+ ) {
416
+ throw new Error("createPropsKit requires a mount element");
417
+ }
418
+
419
+ const mount =
420
+ target instanceof DocumentFragment
421
+ ? (() => {
422
+ const el = document.createElement("div");
423
+ target.append(el);
424
+ return el;
425
+ })()
426
+ : target;
427
+
428
+ const theme = options.theme ?? "system";
429
+ const scoped = options.scoped !== false;
430
+ if (scoped) applyFiguiTheme(mount, theme);
431
+ else applyFiguiTheme(mount, theme);
432
+
433
+ const descriptors = parseConfig(config);
434
+ /** @type {Record<string, unknown>} */
435
+ let values = defaultsFromDescriptors(descriptors);
436
+ const listeners = new Set();
437
+
438
+ const panel = document.createElement("propskit-group");
439
+ if (name) {
440
+ panel.setAttribute("name", name);
441
+ panel.setAttribute("open", "true");
442
+ }
443
+
444
+ const emit = (type, path, value) => {
445
+ if (type === "action") {
446
+ options.onAction?.(path);
447
+ return;
448
+ }
449
+ setPath(values, path, value);
450
+ const snapshot = cloneValues(values);
451
+ options.onChange?.(path, value, snapshot);
452
+ for (const fn of listeners) fn(snapshot);
453
+ mount.dispatchEvent(
454
+ new CustomEvent("change", {
455
+ detail: { path, value, values: snapshot },
456
+ bubbles: true,
457
+ composed: true,
458
+ }),
459
+ );
460
+ };
461
+
462
+ for (const descriptor of descriptors) {
463
+ panel.append(renderControl(descriptor, descriptor.key, values, emit));
464
+ }
465
+
466
+ mount.replaceChildren(panel);
467
+ syncOverlayTheme(mount);
468
+
469
+ const themeObserver = new MutationObserver(() => syncOverlayTheme(mount));
470
+ themeObserver.observe(mount, {
471
+ attributes: true,
472
+ attributeFilter: ["theme", "class"],
473
+ });
474
+
475
+ return {
476
+ get values() {
477
+ return cloneValues(values);
478
+ },
479
+ get(path) {
480
+ return getPath(values, path);
481
+ },
482
+ set(path, value) {
483
+ setPath(values, path, value);
484
+ const snapshot = cloneValues(values);
485
+ options.onChange?.(path, value, snapshot);
486
+ for (const fn of listeners) fn(snapshot);
487
+ // Re-render for simplicity
488
+ mount.replaceChildren();
489
+ const nextPanel = document.createElement("propskit-group");
490
+ if (name) {
491
+ nextPanel.setAttribute("name", name);
492
+ nextPanel.setAttribute("open", "true");
493
+ }
494
+ for (const descriptor of descriptors) {
495
+ nextPanel.append(renderControl(descriptor, descriptor.key, values, emit));
496
+ }
497
+ mount.append(nextPanel);
498
+ },
499
+ subscribe(fn) {
500
+ listeners.add(fn);
501
+ return () => listeners.delete(fn);
502
+ },
503
+ destroy() {
504
+ themeObserver.disconnect();
505
+ listeners.clear();
506
+ mount.replaceChildren();
507
+ },
508
+ };
509
+ }
510
+
511
+ /** Panel shell: marks `.figui-root` and honors `theme`. */
512
+ class FigPanel extends HTMLElement {
513
+ static get observedAttributes() {
514
+ return ["theme"];
515
+ }
516
+
517
+ connectedCallback() {
518
+ this.#apply();
519
+ }
520
+
521
+ attributeChangedCallback() {
522
+ this.#apply();
523
+ }
524
+
525
+ #apply() {
526
+ const theme = this.getAttribute("theme") || "system";
527
+ applyFiguiTheme(this, /** @type {PropsKitTheme} */ (theme));
528
+ syncOverlayTheme(this);
529
+ }
530
+ }
531
+
532
+ if (typeof customElements !== "undefined" && !customElements.get("fig-panel")) {
533
+ customElements.define("fig-panel", FigPanel);
534
+ }
535
+
536
+ // Auto-sync overlay theme when interacting inside a scoped root.
537
+ if (typeof document !== "undefined") {
538
+ document.addEventListener(
539
+ "pointerdown",
540
+ (event) => {
541
+ const t = event.target;
542
+ if (!(t instanceof Element)) return;
543
+ if (t.closest(`.${PROPSKIT_SCOPE_ROOT_CLASS}`)) syncOverlayTheme(t);
544
+ },
545
+ true,
546
+ );
547
+ document.addEventListener(
548
+ "focusin",
549
+ (event) => {
550
+ const t = event.target;
551
+ if (!(t instanceof Element)) return;
552
+ if (t.closest(`.${PROPSKIT_SCOPE_ROOT_CLASS}`)) syncOverlayTheme(t);
553
+ },
554
+ true,
555
+ );
556
+ }