dialkit 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -72,12 +72,24 @@ Returns a fully typed object matching your config shape with live values. Updati
72
72
 
73
73
  ### Slider
74
74
 
75
+ Numbers create sliders. There are three ways to define them:
76
+
77
+ **Explicit range** — `[default, min, max]`:
78
+ ```tsx
79
+ blur: [24, 0, 100]
80
+ ```
81
+
82
+ **Explicit range + step** — `[default, min, max, step]`:
75
83
  ```tsx
76
- blur: [24, 0, 100] // [default, min, max] explicit range
77
- scale: 1.2 // auto-infers range from value
84
+ blur: [24, 0, 100, 5] // snaps in increments of 5
78
85
  ```
86
+ When `step` is omitted, it's inferred from the range (see table below).
79
87
 
80
- Numbers create sliders. With a tuple `[default, min, max]` you set the range explicitly. A bare number auto-infers a reasonable range:
88
+ **Auto-inferred** bare number:
89
+ ```tsx
90
+ scale: 1.2
91
+ ```
92
+ A single number auto-infers a reasonable min, max, and step:
81
93
 
82
94
  | Value range | Inferred min/max | Step |
83
95
  |-------------|-----------------|------|
@@ -197,19 +209,28 @@ Action buttons trigger callbacks without storing any value. The `label` defaults
197
209
 
198
210
  ### Folder
199
211
 
212
+ Any nested plain object becomes a collapsible folder. Folders can nest arbitrarily deep.
213
+
200
214
  ```tsx
201
215
  shadow: {
202
216
  blur: [10, 0, 50],
203
217
  opacity: [0.25, 0, 1],
204
218
  color: '#000000',
205
219
  }
220
+
221
+ // Access nested values:
222
+ params.shadow.blur // number
223
+ params.shadow.color // string
206
224
  ```
207
225
 
208
- Any nested plain object becomes a collapsible folder. Folders can nest arbitrarily deep. Access nested values with dot notation on the returned object:
226
+ Folders are open by default. Add `_collapsed: true` to start a folder closed. This is a reserved metadata key it controls the UI only and won't appear in your returned values.
209
227
 
210
228
  ```tsx
211
- params.shadow.blur // number
212
- params.shadow.color // string
229
+ shadow: {
230
+ _collapsed: true, // folder starts closed
231
+ blur: [10, 0, 50],
232
+ opacity: [0.25, 0, 1],
233
+ }
213
234
  ```
214
235
 
215
236
  ---
package/dist/index.cjs CHANGED
@@ -197,16 +197,17 @@ var DialStoreClass = class {
197
197
  parseConfig(config, prefix) {
198
198
  const controls = [];
199
199
  for (const [key, value] of Object.entries(config)) {
200
+ if (key === "_collapsed") continue;
200
201
  const path = prefix ? `${prefix}.${key}` : key;
201
202
  const label = this.formatLabel(key);
202
- if (Array.isArray(value) && value.length === 3 && typeof value[0] === "number") {
203
+ if (Array.isArray(value) && value.length <= 4 && typeof value[0] === "number") {
203
204
  controls.push({
204
205
  type: "slider",
205
206
  path,
206
207
  label,
207
208
  min: value[1],
208
209
  max: value[2],
209
- step: this.inferStep(value[1], value[2])
210
+ step: value[3] ?? this.inferStep(value[1], value[2])
210
211
  });
211
212
  } else if (typeof value === "number") {
212
213
  const { min, max, step } = this.inferRange(value);
@@ -230,11 +231,14 @@ var DialStoreClass = class {
230
231
  controls.push({ type: "text", path, label });
231
232
  }
232
233
  } else if (typeof value === "object" && value !== null) {
234
+ const folderConfig = value;
235
+ const defaultOpen = "_collapsed" in folderConfig ? !folderConfig._collapsed : true;
233
236
  controls.push({
234
237
  type: "folder",
235
238
  path,
236
239
  label,
237
- children: this.parseConfig(value, path)
240
+ defaultOpen,
241
+ children: this.parseConfig(folderConfig, path)
238
242
  });
239
243
  }
240
244
  }
@@ -243,8 +247,9 @@ var DialStoreClass = class {
243
247
  flattenValues(config, prefix) {
244
248
  const values = {};
245
249
  for (const [key, value] of Object.entries(config)) {
250
+ if (key === "_collapsed") continue;
246
251
  const path = prefix ? `${prefix}.${key}` : key;
247
- if (Array.isArray(value) && value.length === 3 && typeof value[0] === "number") {
252
+ if (Array.isArray(value) && value.length <= 4 && typeof value[0] === "number") {
248
253
  values[path] = value[0];
249
254
  } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
250
255
  values[path] = value;
@@ -336,8 +341,9 @@ function useDialKit(name, config, options) {
336
341
  function buildResolvedValues(config, flatValues, prefix) {
337
342
  const result = {};
338
343
  for (const [key, configValue] of Object.entries(config)) {
344
+ if (key === "_collapsed") continue;
339
345
  const path = prefix ? `${prefix}.${key}` : key;
340
- if (Array.isArray(configValue) && configValue.length === 3 && typeof configValue[0] === "number") {
346
+ if (Array.isArray(configValue) && configValue.length <= 4 && typeof configValue[0] === "number") {
341
347
  result[key] = flatValues[path] ?? configValue[0];
342
348
  } else if (typeof configValue === "number" || typeof configValue === "boolean" || typeof configValue === "string") {
343
349
  result[key] = flatValues[path] ?? configValue;
@@ -383,7 +389,7 @@ function getFirstOptionValue(options) {
383
389
 
384
390
  // src/components/DialRoot.tsx
385
391
  var import_react16 = require("react");
386
- var import_react_dom2 = require("react-dom");
392
+ var import_react_dom3 = require("react-dom");
387
393
 
388
394
  // src/components/Panel.tsx
389
395
  var import_react14 = require("react");
@@ -501,10 +507,14 @@ var CLICK_THRESHOLD = 3;
501
507
  var DEAD_ZONE = 32;
502
508
  var MAX_CURSOR_RANGE = 200;
503
509
  var MAX_STRETCH = 8;
510
+ function decimalsForStep(step) {
511
+ const s = step.toString();
512
+ const dot = s.indexOf(".");
513
+ return dot === -1 ? 0 : s.length - dot - 1;
514
+ }
504
515
  function roundValue(val, step) {
505
- const decimals = step >= 1 ? 0 : 2;
506
- const factor = 10 ** decimals;
507
- return Math.round(val * factor) / factor;
516
+ const raw = Math.round(val / step) * step;
517
+ return parseFloat(raw.toFixed(decimalsForStep(step)));
508
518
  }
509
519
  function snapToDecile(rawValue, min, max) {
510
520
  const normalized = (rawValue - min) / (max - min);
@@ -652,7 +662,8 @@ function Slider({
652
662
  if (!isInteracting) return;
653
663
  if (isClickRef.current) {
654
664
  const rawValue = positionToValue(e.clientX);
655
- const snappedValue = snapToDecile(rawValue, min, max);
665
+ const discreteSteps2 = (max - min) / step;
666
+ const snappedValue = discreteSteps2 <= 10 ? Math.max(min, Math.min(max, min + Math.round((rawValue - min) / step) * step)) : snapToDecile(rawValue, min, max);
656
667
  const newPct = percentFromValue(snappedValue);
657
668
  if (animRef.current) {
658
669
  animRef.current.stop();
@@ -732,7 +743,7 @@ function Slider({
732
743
  e.stopPropagation();
733
744
  e.preventDefault();
734
745
  setShowInput(true);
735
- setInputValue(step >= 1 ? value.toFixed(0) : value.toFixed(2));
746
+ setInputValue(value.toFixed(decimalsForStep(step)));
736
747
  }
737
748
  };
738
749
  const handleInputKeyDown = (e) => {
@@ -746,7 +757,7 @@ function Slider({
746
757
  const handleInputBlur = () => {
747
758
  handleInputSubmit();
748
759
  };
749
- const displayValue = step >= 1 ? value.toFixed(0) : value.toFixed(2);
760
+ const displayValue = value.toFixed(decimalsForStep(step));
750
761
  const HANDLE_BUFFER = 8;
751
762
  const LABEL_CSS_LEFT = 10;
752
763
  const VALUE_CSS_RIGHT = 10;
@@ -764,7 +775,18 @@ function Slider({
764
775
  const valueDodge = percentage < leftThreshold || percentage > rightThreshold;
765
776
  const handleOpacity = !isActive ? 0 : valueDodge ? 0.1 : isDragging ? 0.9 : 0.5;
766
777
  const fillBackground = isActive ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.11)";
767
- const hashMarks = Array.from({ length: 9 }, (_, i) => {
778
+ const discreteSteps = (max - min) / step;
779
+ const hashMarks = discreteSteps <= 10 ? Array.from({ length: discreteSteps - 1 }, (_, i) => {
780
+ const pct = (i + 1) * step / (max - min) * 100;
781
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
782
+ "div",
783
+ {
784
+ className: "dialkit-slider-hashmark",
785
+ style: { left: `${pct}%` }
786
+ },
787
+ i
788
+ );
789
+ }) : Array.from({ length: 9 }, (_, i) => {
768
790
  const pct = (i + 1) * 10;
769
791
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
770
792
  "div",
@@ -1154,6 +1176,7 @@ function TextControl({ label, value, onChange, placeholder }) {
1154
1176
 
1155
1177
  // src/components/SelectControl.tsx
1156
1178
  var import_react9 = require("react");
1179
+ var import_react_dom = require("react-dom");
1157
1180
  var import_react10 = require("motion/react");
1158
1181
  var import_jsx_runtime8 = require("react/jsx-runtime");
1159
1182
  function toTitleCase(s) {
@@ -1166,23 +1189,50 @@ function normalizeOptions(options) {
1166
1189
  }
1167
1190
  function SelectControl({ label, value, options, onChange }) {
1168
1191
  const [isOpen, setIsOpen] = (0, import_react9.useState)(false);
1169
- const containerRef = (0, import_react9.useRef)(null);
1192
+ const triggerRef = (0, import_react9.useRef)(null);
1193
+ const dropdownRef = (0, import_react9.useRef)(null);
1194
+ const [portalTarget, setPortalTarget] = (0, import_react9.useState)(null);
1195
+ const [pos, setPos] = (0, import_react9.useState)(null);
1170
1196
  const normalized = normalizeOptions(options);
1171
1197
  const selectedOption = normalized.find((o) => o.value === value);
1198
+ const updatePos = (0, import_react9.useCallback)(() => {
1199
+ const el = triggerRef.current;
1200
+ if (!el) return;
1201
+ const rect = el.getBoundingClientRect();
1202
+ const dropdownHeight = 8 + normalized.length * 36;
1203
+ const spaceBelow = window.innerHeight - rect.bottom - 4;
1204
+ const above = spaceBelow < dropdownHeight && rect.top > spaceBelow;
1205
+ setPos({
1206
+ top: above ? rect.top - 4 : rect.bottom + 4,
1207
+ left: rect.left,
1208
+ width: rect.width,
1209
+ above
1210
+ });
1211
+ }, [normalized.length]);
1212
+ (0, import_react9.useEffect)(() => {
1213
+ const root = triggerRef.current?.closest(".dialkit-root");
1214
+ setPortalTarget(root ?? document.body);
1215
+ }, []);
1216
+ (0, import_react9.useEffect)(() => {
1217
+ if (!isOpen) return;
1218
+ updatePos();
1219
+ }, [isOpen, updatePos]);
1172
1220
  (0, import_react9.useEffect)(() => {
1173
1221
  if (!isOpen) return;
1174
1222
  const handleClick = (e) => {
1175
- if (containerRef.current && !containerRef.current.contains(e.target)) {
1223
+ const target = e.target;
1224
+ if (triggerRef.current && !triggerRef.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target)) {
1176
1225
  setIsOpen(false);
1177
1226
  }
1178
1227
  };
1179
1228
  document.addEventListener("mousedown", handleClick);
1180
1229
  return () => document.removeEventListener("mousedown", handleClick);
1181
1230
  }, [isOpen]);
1182
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { ref: containerRef, className: "dialkit-select-row", children: [
1231
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "dialkit-select-row", children: [
1183
1232
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
1184
1233
  "button",
1185
1234
  {
1235
+ ref: triggerRef,
1186
1236
  className: "dialkit-select-trigger",
1187
1237
  onClick: () => setIsOpen(!isOpen),
1188
1238
  "data-open": String(isOpen),
@@ -1209,29 +1259,39 @@ function SelectControl({ label, value, options, onChange }) {
1209
1259
  ]
1210
1260
  }
1211
1261
  ),
1212
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react10.AnimatePresence, { children: isOpen && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1213
- import_react10.motion.div,
1214
- {
1215
- className: "dialkit-select-dropdown",
1216
- initial: { opacity: 0, y: -8, scale: 0.95 },
1217
- animate: { opacity: 1, y: 0, scale: 1 },
1218
- exit: { opacity: 0, y: -8, scale: 0.95 },
1219
- transition: { type: "spring", visualDuration: 0.15, bounce: 0 },
1220
- children: normalized.map((option) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1221
- "button",
1222
- {
1223
- className: "dialkit-select-option",
1224
- "data-selected": String(option.value === value),
1225
- onClick: () => {
1226
- onChange(option.value);
1227
- setIsOpen(false);
1228
- },
1229
- children: option.label
1262
+ portalTarget && (0, import_react_dom.createPortal)(
1263
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react10.AnimatePresence, { children: isOpen && pos && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1264
+ import_react10.motion.div,
1265
+ {
1266
+ ref: dropdownRef,
1267
+ className: "dialkit-select-dropdown",
1268
+ initial: { opacity: 0, y: pos.above ? 8 : -8, scale: 0.95 },
1269
+ animate: { opacity: 1, y: 0, scale: 1 },
1270
+ exit: { opacity: 0, y: pos.above ? 8 : -8, scale: 0.95 },
1271
+ transition: { type: "spring", visualDuration: 0.15, bounce: 0 },
1272
+ style: {
1273
+ position: "fixed",
1274
+ left: pos.left,
1275
+ width: pos.width,
1276
+ ...pos.above ? { bottom: window.innerHeight - pos.top, transformOrigin: "bottom" } : { top: pos.top, transformOrigin: "top" }
1230
1277
  },
1231
- option.value
1232
- ))
1233
- }
1234
- ) })
1278
+ children: normalized.map((option) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1279
+ "button",
1280
+ {
1281
+ className: "dialkit-select-option",
1282
+ "data-selected": String(option.value === value),
1283
+ onClick: () => {
1284
+ onChange(option.value);
1285
+ setIsOpen(false);
1286
+ },
1287
+ children: option.label
1288
+ },
1289
+ option.value
1290
+ ))
1291
+ }
1292
+ ) }),
1293
+ portalTarget
1294
+ )
1235
1295
  ] });
1236
1296
  }
1237
1297
 
@@ -1315,7 +1375,7 @@ function expandShorthandHex(hex) {
1315
1375
 
1316
1376
  // src/components/PresetManager.tsx
1317
1377
  var import_react12 = require("react");
1318
- var import_react_dom = require("react-dom");
1378
+ var import_react_dom2 = require("react-dom");
1319
1379
  var import_react13 = require("motion/react");
1320
1380
  var import_jsx_runtime10 = require("react/jsx-runtime");
1321
1381
  function PresetManager({ panelId, presets, activePresetId, onAdd }) {
@@ -1390,12 +1450,12 @@ function PresetManager({ panelId, presets, activePresetId, onAdd }) {
1390
1450
  ]
1391
1451
  }
1392
1452
  ),
1393
- (0, import_react_dom.createPortal)(
1453
+ (0, import_react_dom2.createPortal)(
1394
1454
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react13.AnimatePresence, { children: isOpen && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1395
1455
  import_react13.motion.div,
1396
1456
  {
1397
1457
  ref: dropdownRef,
1398
- className: "dialkit-preset-dropdown",
1458
+ className: "dialkit-root dialkit-preset-dropdown",
1399
1459
  style: { position: "fixed", top: pos.top, left: pos.left, minWidth: pos.width },
1400
1460
  initial: { opacity: 0, y: 4, scale: 0.97 },
1401
1461
  animate: { opacity: 1, y: 0, scale: 1 },
@@ -1514,7 +1574,7 @@ Apply these values as the new defaults in the useDialKit call.`;
1514
1574
  control.path
1515
1575
  );
1516
1576
  case "folder":
1517
- return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Folder, { title: control.label, children: control.children?.map(renderControl) }, control.path);
1577
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Folder, { title: control.label, defaultOpen: control.defaultOpen ?? true, children: control.children?.map(renderControl) }, control.path);
1518
1578
  case "text":
1519
1579
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1520
1580
  TextControl,
@@ -1675,7 +1735,7 @@ function DialRoot({ position = "top-right" }) {
1675
1735
  return null;
1676
1736
  }
1677
1737
  const content = /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "dialkit-root", children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "dialkit-panel", "data-position": position, children: panels.map((panel) => /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Panel, { panel }, panel.id)) }) });
1678
- return (0, import_react_dom2.createPortal)(content, document.body);
1738
+ return (0, import_react_dom3.createPortal)(content, document.body);
1679
1739
  }
1680
1740
 
1681
1741
  // src/components/ButtonGroup.tsx