@shadow-js/ui 1.0.1 → 1.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ ### Fixed
6
+
7
+ - `Carousel` runs the `onPointerEnter`, `onPointerLeave`, `onFocusIn` and `onFocusOut` handlers you pass (they were dropped), takes `[handler, data]` tuples for its handlers and for `InputGroupAddon`'s `onClick`, and never starts autoplay with a `NaN` or infinite interval.
8
+ - A carousel whose slides come and go (a list that shrinks, is filtered or is replaced) counts only the slides that exist: the "n of N" names, the indicators, `scrollSnapList()` and the current index follow the list, and `reInit` fires once after the change settles.
9
+ - `ChartContainer` falls back to its default width for a `NaN` or infinite `initialWidth`, and a `ResizableHandle` that unmounts mid-drag stops listening to the pointer.
10
+ - `shadow-ui add` refuses to write through a dangling symbolic link that points outside the project, reads `package.json`, `components.json` and tsconfig files that start with a byte order mark, reports a tsconfig that is not JSON as an invalid file instead of crashing, and treats the end of input (Ctrl-D) at a prompt as "no" instead of exiting with code 13.
11
+ - `input-group` and `carousel` now import `callHandler` and `mergeElementProps` from `@shadow-js/primitives`; run `shadow-ui diff` to see the update for components you copied earlier.
12
+ - `shadow-ui add --path <dir>` writes the imports between the copied ui components as `./name` when `<dir>` is not the directory the `ui` alias names (they used to import from the alias, a directory that does not hold them); with the default directory nothing changes.
13
+ - `shadow-ui add` and `diff` keep at most 16 million array cells (64 MB) while diffing; two files that share almost nothing become one replacement instead of allocating gigabytes.
14
+ - `ResizablePanelGroup` follows its panels: a panel that mounts after the first render takes its place in document order and a share of the 100 percent, a panel that unmounts gives its share back, and the handles keep resizing the panels on both sides of them. A remembered layout is brought inside the panels' `minSize` and `maxSize` and scaled to sum to 100 (one with a negative, infinite or missing size is ignored).
15
+ - `ResizablePanelGroup`, `ResizablePanel` and `ResizableHandle` are tested to call a `ref` the application passes next to their own (they already did; the test pins it).
16
+ - `shadow-ui add` can no longer write outside the project through a dangling relative symlink inside a linked folder: the path guard resolves the link's target from the folder's real place, as the operating system does, instead of from the folder as written.
17
+
3
18
  ## 1.0.1
4
19
 
5
20
  ### Changed
package/dist/cli.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { createInterface } from "node:readline/promises";
5
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
5
  import { parseArgs } from "node:util";
6
+ import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  // src/cli/fs.ts
9
9
  /**
@@ -11,6 +11,38 @@ import { fileURLToPath } from "node:url";
11
11
  * directory or an in-memory tree, and so every path goes through one place.
12
12
  * @packageDocumentation
13
13
  */
14
+ /** Symbolic links followed while resolving one path; the same bound the operating system uses. */
15
+ const MAX_LINK_HOPS = 40;
16
+ /** Whether a failed file call means "nothing is there" (or a file stands where a directory should). */
17
+ function isMissing(error) {
18
+ const code = error.code;
19
+ return code === "ENOENT" || code === "ENOTDIR";
20
+ }
21
+ /**
22
+ * The real path of `path`, where the part that does not exist yet is kept as written. A symbolic
23
+ * link whose target is missing is followed to that target: a write through it would create the
24
+ * target, so the guard has to see where it is.
25
+ */
26
+ function resolveReal(path, hops) {
27
+ try {
28
+ return realpathSync(path);
29
+ } catch (error) {
30
+ if (!isMissing(error)) throw error;
31
+ }
32
+ let link;
33
+ try {
34
+ link = readlinkSync(path);
35
+ } catch (error) {
36
+ if (!isMissing(error) && error.code !== "EINVAL") throw error;
37
+ }
38
+ if (link !== void 0) {
39
+ if (hops >= MAX_LINK_HOPS) throw Object.assign(/* @__PURE__ */ new Error(`Too many symbolic links: ${path}`), { code: "ELOOP" });
40
+ return resolveReal(resolve(resolveReal(dirname(path), hops + 1), link), hops + 1);
41
+ }
42
+ const parent = dirname(path);
43
+ if (parent === path) return path;
44
+ return join(resolveReal(parent, hops), basename(path));
45
+ }
14
46
  /**
15
47
  * The real filesystem.
16
48
  *
@@ -26,7 +58,7 @@ function nodeFs() {
26
58
  exists: (path) => existsSync(path),
27
59
  read(path) {
28
60
  try {
29
- return readFileSync(path, "utf8");
61
+ return readFileSync(path, "utf8").replace(/^\uFEFF/, "");
30
62
  } catch (error) {
31
63
  if (error.code === "ENOENT") return void 0;
32
64
  throw error;
@@ -40,15 +72,7 @@ function nodeFs() {
40
72
  rmSync(path, { force: true });
41
73
  },
42
74
  realpath(path) {
43
- const tail = [];
44
- let current = path;
45
- while (!existsSync(current)) {
46
- const parent = dirname(current);
47
- if (parent === current) return path;
48
- tail.unshift(basename(current));
49
- current = parent;
50
- }
51
- return join(realpathSync(current), ...tail);
75
+ return resolveReal(path, 0);
52
76
  }
53
77
  };
54
78
  }
@@ -210,25 +234,37 @@ function installCrashHandlers(argv) {
210
234
  process.on("uncaughtException", crash);
211
235
  process.on("unhandledRejection", crash);
212
236
  }
213
- /** The edit script turning `a` into `b`; one big replacement when the texts are too large. */
214
- function edits(a, b) {
215
- const n = a.length;
216
- const m = b.length;
217
- const max = n + m;
218
- if (max > 2e4) return [...a.map((line) => ({
237
+ /** The whole of `a` deleted and the whole of `b` inserted: always correct, never minimal. */
238
+ function replacement(a, b) {
239
+ return [...a.map((line) => ({
219
240
  kind: "delete",
220
241
  line
221
242
  })), ...b.map((line) => ({
222
243
  kind: "insert",
223
244
  line
224
245
  }))];
246
+ }
247
+ /** The edit script turning `a` into `b`; one big replacement when the texts are too large or too different. */
248
+ function edits(a, b) {
249
+ const n = a.length;
250
+ const m = b.length;
251
+ const max = n + m;
252
+ if (max > 2e4) return replacement(a, b);
225
253
  const offset = max;
226
- let frontier = new Int32Array(2 * max + 2);
254
+ const frontier = new Int32Array(2 * max + 2);
255
+ /** The frontier before each round, only the diagonals the next round can read: `[offset - d - 1, offset + d + 1]`. */
227
256
  const trace = [];
257
+ let kept = 0;
228
258
  let reached = -1;
229
259
  for (let d = 0; d <= max && reached === -1; d += 1) {
230
- trace.push(frontier.slice());
231
- const next = frontier.slice();
260
+ const from = Math.max(0, offset - d - 1);
261
+ const cells = frontier.slice(from, Math.min(frontier.length, offset + d + 2));
262
+ kept += cells.length;
263
+ if (kept > 16e6) return replacement(a, b);
264
+ trace.push({
265
+ from,
266
+ cells
267
+ });
232
268
  for (let k = -d; k <= d; k += 2) {
233
269
  let x;
234
270
  const below = frontier[offset + k - 1] ?? 0;
@@ -240,24 +276,24 @@ function edits(a, b) {
240
276
  x += 1;
241
277
  y += 1;
242
278
  }
243
- next[offset + k] = x;
279
+ frontier[offset + k] = x;
244
280
  if (x >= n && y >= m) {
245
281
  reached = d;
246
282
  break;
247
283
  }
248
284
  }
249
- frontier = next;
250
285
  }
251
286
  const result = [];
252
287
  let x = n;
253
288
  let y = m;
254
289
  for (let d = reached; d > 0; d -= 1) {
255
- const previous = trace[d] ?? /* @__PURE__ */ new Int32Array(0);
290
+ const previous = trace[d];
291
+ const at = (index) => previous === void 0 ? 0 : previous.cells[index - previous.from] ?? 0;
256
292
  const k = x - y;
257
- const below = previous[offset + k - 1] ?? 0;
258
- const above = previous[offset + k + 1] ?? 0;
293
+ const below = at(offset + k - 1);
294
+ const above = at(offset + k + 1);
259
295
  const previousK = k === -d || k !== d && below < above ? k + 1 : k - 1;
260
- const previousX = previous[offset + previousK] ?? 0;
296
+ const previousX = at(offset + previousK);
261
297
  const previousY = previousX - previousK;
262
298
  while (x > previousX && y > previousY) {
263
299
  x -= 1;
@@ -371,6 +407,186 @@ function unifiedDiff(before, after, labels, context = 3) {
371
407
  ...hunks
372
408
  ].join("\n");
373
409
  }
410
+ // src/cli/schema.ts
411
+ /**
412
+ * The base colors, in documentation order.
413
+ * @since 1.0.0
414
+ *
415
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
416
+ */
417
+ const BASE_COLORS = [
418
+ "neutral",
419
+ "zinc",
420
+ "slate",
421
+ "stone",
422
+ "gray"
423
+ ];
424
+ /**
425
+ * The default aliases (shadcn's).
426
+ * @since 1.0.0
427
+ *
428
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
429
+ */
430
+ const DEFAULT_ALIASES = {
431
+ components: "@/components",
432
+ ui: "@/components/ui",
433
+ lib: "@/lib",
434
+ utils: "@/lib/utils",
435
+ hooks: "@/hooks"
436
+ };
437
+ /** Registry item names are kebab-case; nothing else may reach a file path. */
438
+ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
439
+ /** A file name inside `registry/<name>/`. */
440
+ const FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
441
+ /**
442
+ * Whether `name` is a valid registry item name.
443
+ *
444
+ * @param name Any string.
445
+ * @returns `true` for kebab-case names such as `dropdown-menu`.
446
+ * @example
447
+ * isValidItemName("../etc/passwd"); // false
448
+ * @since 1.0.0
449
+ *
450
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
451
+ */
452
+ function isValidItemName(name) {
453
+ return NAME_PATTERN.test(name);
454
+ }
455
+ /**
456
+ * Thrown for malformed registry or configuration data.
457
+ * @since 1.0.0
458
+ *
459
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
460
+ */
461
+ var SchemaError = class extends Error {
462
+ /** The stable code `--json` prints. */
463
+ code = "SHADOW_UI_INVALID_FILE";
464
+ constructor(message) {
465
+ super(message);
466
+ this.name = "SchemaError";
467
+ }
468
+ };
469
+ function record(value, where) {
470
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SchemaError(`${where} must be an object`);
471
+ return value;
472
+ }
473
+ function text(value, where) {
474
+ if (typeof value !== "string" || value === "") throw new SchemaError(`${where} must be a string`);
475
+ return value;
476
+ }
477
+ function strings(value, where) {
478
+ if (!Array.isArray(value)) throw new SchemaError(`${where} must be an array`);
479
+ return value.map((entry, index) => text(entry, `${where}[${index}]`));
480
+ }
481
+ function itemType(value, where) {
482
+ if (value === "ui" || value === "lib" || value === "hook") return value;
483
+ throw new SchemaError(`${where} must be "ui", "lib" or "hook"`);
484
+ }
485
+ function parseItem(value, index) {
486
+ const where = `items[${index}]`;
487
+ const item = record(value, where);
488
+ const name = text(item["name"], `${where}.name`);
489
+ if (!isValidItemName(name)) throw new SchemaError(`${where}.name "${name}" is not a valid name`);
490
+ const files = item["files"];
491
+ if (!Array.isArray(files) || files.length === 0) throw new SchemaError(`${where}.files must be a non-empty array`);
492
+ const dependencies = record(item["dependencies"], `${where}.dependencies`);
493
+ const tailwind = record(item["tailwind"], `${where}.tailwind`);
494
+ return {
495
+ name,
496
+ type: itemType(item["type"], `${where}.type`),
497
+ title: text(item["title"], `${where}.title`),
498
+ description: text(item["description"], `${where}.description`),
499
+ files: files.map((file, fileIndex) => {
500
+ const entry = record(file, `${where}.files[${fileIndex}]`);
501
+ const path = text(entry["path"], `${where}.files[${fileIndex}].path`);
502
+ const segments = path.split("/");
503
+ const [root, folder, base] = segments;
504
+ if (segments.length !== 3 || root !== "registry" || folder !== name || base === void 0 || !FILE_PATTERN.test(base)) throw new SchemaError(`${where}.files[${fileIndex}].path "${path}" must be registry/${name}/<file>`);
505
+ return {
506
+ path,
507
+ type: itemType(entry["type"], `${where}.files[${fileIndex}].type`)
508
+ };
509
+ }),
510
+ registryDependencies: strings(item["registryDependencies"], `${where}.registryDependencies`),
511
+ dependencies: Object.fromEntries(Object.entries(dependencies).map(([pkg, version]) => [pkg, text(version, `${where}.dependencies.${pkg}`)])),
512
+ exports: strings(item["exports"], `${where}.exports`),
513
+ primitives: strings(item["primitives"], `${where}.primitives`),
514
+ tailwind: {
515
+ tokens: strings(tailwind["tokens"], `${where}.tailwind.tokens`),
516
+ animations: tailwind["animations"] === true
517
+ }
518
+ };
519
+ }
520
+ /**
521
+ * Validates parsed `registry.json` data.
522
+ *
523
+ * @param value The parsed JSON.
524
+ * @returns The typed index.
525
+ * @throws SchemaError naming the first malformed field.
526
+ * @example
527
+ * const index = parseRegistryIndex(JSON.parse(readFileSync("registry.json", "utf8")));
528
+ * @since 1.0.0
529
+ *
530
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
531
+ */
532
+ function parseRegistryIndex(value) {
533
+ const root = record(value, "registry");
534
+ const items = root["items"];
535
+ if (!Array.isArray(items)) throw new SchemaError("registry.items must be an array");
536
+ const parsed = items.map(parseItem);
537
+ const seen = /* @__PURE__ */ new Set();
538
+ for (const item of parsed) {
539
+ if (seen.has(item.name)) throw new SchemaError(`registry has two items named "${item.name}"`);
540
+ seen.add(item.name);
541
+ }
542
+ return {
543
+ name: text(root["name"], "registry.name"),
544
+ version: text(root["version"], "registry.version"),
545
+ frameworkDependencies: strings(root["frameworkDependencies"], "registry.frameworkDependencies"),
546
+ items: parsed
547
+ };
548
+ }
549
+ /**
550
+ * Validates parsed `components.json` data.
551
+ *
552
+ * @param value The parsed JSON.
553
+ * @returns The typed configuration, with defaults for missing aliases.
554
+ * @throws SchemaError naming the first malformed field.
555
+ * @example
556
+ * const config = parseComponentsConfig(JSON.parse(readFileSync("components.json", "utf8")));
557
+ * @since 1.0.0
558
+ *
559
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
560
+ */
561
+ function parseComponentsConfig(value) {
562
+ const root = record(value, "components.json");
563
+ const tailwind = record(root["tailwind"], "components.json tailwind");
564
+ const baseColor = tailwind["baseColor"] ?? "neutral";
565
+ if (!BASE_COLORS.includes(baseColor)) throw new SchemaError(`components.json tailwind.baseColor must be one of ${BASE_COLORS.join(", ")}`);
566
+ const given = root["aliases"] === void 0 ? {} : record(root["aliases"], "components.json aliases");
567
+ const alias = (key) => {
568
+ const entry = given[key];
569
+ if (entry === void 0) return DEFAULT_ALIASES[key];
570
+ const aliasText = text(entry, `components.json aliases.${key}`);
571
+ if (aliasText.includes("..") || /\s/.test(aliasText)) throw new SchemaError(`components.json aliases.${key} "${aliasText}" is not a valid alias`);
572
+ return aliasText;
573
+ };
574
+ const framework = root["framework"];
575
+ return {
576
+ framework: framework === "vite" || framework === "dusk" ? framework : "unknown",
577
+ tailwind: {
578
+ css: text(tailwind["css"], "components.json tailwind.css"),
579
+ baseColor
580
+ },
581
+ aliases: {
582
+ components: alias("components"),
583
+ ui: alias("ui"),
584
+ lib: alias("lib"),
585
+ utils: alias("utils"),
586
+ hooks: alias("hooks")
587
+ }
588
+ };
589
+ }
374
590
  // src/cli/paths.ts
375
591
  /**
376
592
  * Path handling: reading `tsconfig` path aliases, mapping an import alias to a directory, and the
@@ -477,7 +693,14 @@ function readPathMappings(fs, root) {
477
693
  for (const name of ["tsconfig.json", "tsconfig.app.json"]) {
478
694
  const text = fs.read(join(root, name));
479
695
  if (text === void 0) continue;
480
- const options = asRecord(asRecord(parseJsonc(text))?.["compilerOptions"]);
696
+ let parsed;
697
+ try {
698
+ parsed = parseJsonc(text);
699
+ } catch (error) {
700
+ if (error instanceof SyntaxError) throw new SchemaError(`${name} is not valid JSON: ${error.message}`);
701
+ throw error;
702
+ }
703
+ const options = asRecord(asRecord(parsed)?.["compilerOptions"]);
481
704
  const paths = asRecord(options?.["paths"]);
482
705
  if (paths === void 0) continue;
483
706
  const base = typeof options?.["baseUrl"] === "string" ? resolve(root, options["baseUrl"]) : root;
@@ -633,186 +856,6 @@ function installCommand(manager, packages) {
633
856
  };
634
857
  }
635
858
  }
636
- // src/cli/schema.ts
637
- /**
638
- * The base colors, in documentation order.
639
- * @since 1.0.0
640
- *
641
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
642
- */
643
- const BASE_COLORS = [
644
- "neutral",
645
- "zinc",
646
- "slate",
647
- "stone",
648
- "gray"
649
- ];
650
- /**
651
- * The default aliases (shadcn's).
652
- * @since 1.0.0
653
- *
654
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
655
- */
656
- const DEFAULT_ALIASES = {
657
- components: "@/components",
658
- ui: "@/components/ui",
659
- lib: "@/lib",
660
- utils: "@/lib/utils",
661
- hooks: "@/hooks"
662
- };
663
- /** Registry item names are kebab-case; nothing else may reach a file path. */
664
- const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
665
- /** A file name inside `registry/<name>/`. */
666
- const FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
667
- /**
668
- * Whether `name` is a valid registry item name.
669
- *
670
- * @param name Any string.
671
- * @returns `true` for kebab-case names such as `dropdown-menu`.
672
- * @example
673
- * isValidItemName("../etc/passwd"); // false
674
- * @since 1.0.0
675
- *
676
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
677
- */
678
- function isValidItemName(name) {
679
- return NAME_PATTERN.test(name);
680
- }
681
- /**
682
- * Thrown for malformed registry or configuration data.
683
- * @since 1.0.0
684
- *
685
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
686
- */
687
- var SchemaError = class extends Error {
688
- /** The stable code `--json` prints. */
689
- code = "SHADOW_UI_INVALID_FILE";
690
- constructor(message) {
691
- super(message);
692
- this.name = "SchemaError";
693
- }
694
- };
695
- function record(value, where) {
696
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SchemaError(`${where} must be an object`);
697
- return value;
698
- }
699
- function text(value, where) {
700
- if (typeof value !== "string" || value === "") throw new SchemaError(`${where} must be a string`);
701
- return value;
702
- }
703
- function strings(value, where) {
704
- if (!Array.isArray(value)) throw new SchemaError(`${where} must be an array`);
705
- return value.map((entry, index) => text(entry, `${where}[${index}]`));
706
- }
707
- function itemType(value, where) {
708
- if (value === "ui" || value === "lib" || value === "hook") return value;
709
- throw new SchemaError(`${where} must be "ui", "lib" or "hook"`);
710
- }
711
- function parseItem(value, index) {
712
- const where = `items[${index}]`;
713
- const item = record(value, where);
714
- const name = text(item["name"], `${where}.name`);
715
- if (!isValidItemName(name)) throw new SchemaError(`${where}.name "${name}" is not a valid name`);
716
- const files = item["files"];
717
- if (!Array.isArray(files) || files.length === 0) throw new SchemaError(`${where}.files must be a non-empty array`);
718
- const dependencies = record(item["dependencies"], `${where}.dependencies`);
719
- const tailwind = record(item["tailwind"], `${where}.tailwind`);
720
- return {
721
- name,
722
- type: itemType(item["type"], `${where}.type`),
723
- title: text(item["title"], `${where}.title`),
724
- description: text(item["description"], `${where}.description`),
725
- files: files.map((file, fileIndex) => {
726
- const entry = record(file, `${where}.files[${fileIndex}]`);
727
- const path = text(entry["path"], `${where}.files[${fileIndex}].path`);
728
- const segments = path.split("/");
729
- const [root, folder, base] = segments;
730
- if (segments.length !== 3 || root !== "registry" || folder !== name || base === void 0 || !FILE_PATTERN.test(base)) throw new SchemaError(`${where}.files[${fileIndex}].path "${path}" must be registry/${name}/<file>`);
731
- return {
732
- path,
733
- type: itemType(entry["type"], `${where}.files[${fileIndex}].type`)
734
- };
735
- }),
736
- registryDependencies: strings(item["registryDependencies"], `${where}.registryDependencies`),
737
- dependencies: Object.fromEntries(Object.entries(dependencies).map(([pkg, version]) => [pkg, text(version, `${where}.dependencies.${pkg}`)])),
738
- exports: strings(item["exports"], `${where}.exports`),
739
- primitives: strings(item["primitives"], `${where}.primitives`),
740
- tailwind: {
741
- tokens: strings(tailwind["tokens"], `${where}.tailwind.tokens`),
742
- animations: tailwind["animations"] === true
743
- }
744
- };
745
- }
746
- /**
747
- * Validates parsed `registry.json` data.
748
- *
749
- * @param value The parsed JSON.
750
- * @returns The typed index.
751
- * @throws SchemaError naming the first malformed field.
752
- * @example
753
- * const index = parseRegistryIndex(JSON.parse(readFileSync("registry.json", "utf8")));
754
- * @since 1.0.0
755
- *
756
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
757
- */
758
- function parseRegistryIndex(value) {
759
- const root = record(value, "registry");
760
- const items = root["items"];
761
- if (!Array.isArray(items)) throw new SchemaError("registry.items must be an array");
762
- const parsed = items.map(parseItem);
763
- const seen = /* @__PURE__ */ new Set();
764
- for (const item of parsed) {
765
- if (seen.has(item.name)) throw new SchemaError(`registry has two items named "${item.name}"`);
766
- seen.add(item.name);
767
- }
768
- return {
769
- name: text(root["name"], "registry.name"),
770
- version: text(root["version"], "registry.version"),
771
- frameworkDependencies: strings(root["frameworkDependencies"], "registry.frameworkDependencies"),
772
- items: parsed
773
- };
774
- }
775
- /**
776
- * Validates parsed `components.json` data.
777
- *
778
- * @param value The parsed JSON.
779
- * @returns The typed configuration, with defaults for missing aliases.
780
- * @throws SchemaError naming the first malformed field.
781
- * @example
782
- * const config = parseComponentsConfig(JSON.parse(readFileSync("components.json", "utf8")));
783
- * @since 1.0.0
784
- *
785
- * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
786
- */
787
- function parseComponentsConfig(value) {
788
- const root = record(value, "components.json");
789
- const tailwind = record(root["tailwind"], "components.json tailwind");
790
- const baseColor = tailwind["baseColor"] ?? "neutral";
791
- if (!BASE_COLORS.includes(baseColor)) throw new SchemaError(`components.json tailwind.baseColor must be one of ${BASE_COLORS.join(", ")}`);
792
- const given = root["aliases"] === void 0 ? {} : record(root["aliases"], "components.json aliases");
793
- const alias = (key) => {
794
- const entry = given[key];
795
- if (entry === void 0) return DEFAULT_ALIASES[key];
796
- const aliasText = text(entry, `components.json aliases.${key}`);
797
- if (aliasText.includes("..") || /\s/.test(aliasText)) throw new SchemaError(`components.json aliases.${key} "${aliasText}" is not a valid alias`);
798
- return aliasText;
799
- };
800
- const framework = root["framework"];
801
- return {
802
- framework: framework === "vite" || framework === "dusk" ? framework : "unknown",
803
- tailwind: {
804
- css: text(tailwind["css"], "components.json tailwind.css"),
805
- baseColor
806
- },
807
- aliases: {
808
- components: alias("components"),
809
- ui: alias("ui"),
810
- lib: alias("lib"),
811
- utils: alias("utils"),
812
- hooks: alias("hooks")
813
- }
814
- };
815
- }
816
859
  // src/cli/project.ts
817
860
  /**
818
861
  * The project the CLI works in: `components.json`, `package.json`, the stylesheet that imports
@@ -1243,6 +1286,7 @@ function dependentsOf(index, name, candidates) {
1243
1286
  *
1244
1287
  * @param specifier An import specifier such as `@/components/ui/button`.
1245
1288
  * @param aliases The aliases from `components.json`.
1289
+ * @param options Rewrites besides the aliases (relative imports between ui components).
1246
1290
  * @returns The rewritten specifier, or the input unchanged when it is not a registry alias.
1247
1291
  * @example
1248
1292
  * rewriteSpecifier("@/lib/utils", { ...aliases, utils: "~/helpers/cn" }); // "~/helpers/cn"
@@ -1250,9 +1294,12 @@ function dependentsOf(index, name, candidates) {
1250
1294
  *
1251
1295
  * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
1252
1296
  */
1253
- function rewriteSpecifier(specifier, aliases) {
1297
+ function rewriteSpecifier(specifier, aliases, options = {}) {
1254
1298
  if (specifier === "@/lib/utils") return aliases.utils;
1255
- if (specifier.startsWith("@/components/ui/")) return `${aliases.ui}/${specifier.slice(16)}`;
1299
+ if (specifier.startsWith("@/components/ui/")) {
1300
+ const rest = specifier.slice(16);
1301
+ return options.relativeUi === true ? `./${rest}` : `${aliases.ui}/${rest}`;
1302
+ }
1256
1303
  if (specifier.startsWith("@/hooks/")) return `${aliases.hooks}/${specifier.slice(8)}`;
1257
1304
  if (specifier.startsWith("@/lib/")) return `${aliases.lib}/${specifier.slice(6)}`;
1258
1305
  return specifier;
@@ -1265,6 +1312,7 @@ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(@\/[^"'\n]+)\2
1265
1312
  *
1266
1313
  * @param source A registry file's text.
1267
1314
  * @param aliases The aliases from `components.json`.
1315
+ * @param options Rewrites besides the aliases (relative imports between ui components).
1268
1316
  * @returns The text with rewritten specifiers.
1269
1317
  * @example
1270
1318
  * rewriteImports('import { cn } from "@/lib/utils";', aliases);
@@ -1272,8 +1320,8 @@ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(@\/[^"'\n]+)\2
1272
1320
  *
1273
1321
  * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
1274
1322
  */
1275
- function rewriteImports(source, aliases) {
1276
- return source.replace(SPECIFIER, (_match, lead, quote, specifier) => `${lead}${quote}${rewriteSpecifier(specifier, aliases)}${quote}`);
1323
+ function rewriteImports(source, aliases, options = {}) {
1324
+ return source.replace(SPECIFIER, (_match, lead, quote, specifier) => `${lead}${quote}${rewriteSpecifier(specifier, aliases, options)}${quote}`);
1277
1325
  }
1278
1326
  // src/cli/commands.ts
1279
1327
  /**
@@ -1334,12 +1382,26 @@ function targetFor(item, file, config, mappings, ctx, pathOverride) {
1334
1382
  if (item.type === "lib" && item.name === "utils") return resolveInsideReal(ctx.fs, ctx.cwd, `${aliasToDirectory(config.aliases.utils, mappings)}${extname(base)}`);
1335
1383
  return resolveInsideReal(ctx.fs, ctx.cwd, join(directoryFor(item, config, mappings, ctx, pathOverride), base));
1336
1384
  }
1385
+ /**
1386
+ * Whether ui components written with `--path` need relative imports between them: they do when that directory is not the one the
1387
+ * `ui` alias names (the alias would point at a directory that does not hold them), or when the alias names no directory at all.
1388
+ */
1389
+ function importsBetweenUiAreRelative(ctx, config, mappings, pathOverride) {
1390
+ if (pathOverride === void 0) return false;
1391
+ try {
1392
+ return resolve(aliasToDirectory(config.aliases.ui, mappings)) !== resolve(ctx.cwd, pathOverride);
1393
+ } catch (error) {
1394
+ if (error instanceof PathError) return true;
1395
+ throw error;
1396
+ }
1397
+ }
1337
1398
  /** Builds the per-file plan for the items, reading what is on disk. */
1338
1399
  function planFiles(ctx, config, items, pathOverride, mappings = readPathMappings(ctx.fs, ctx.cwd)) {
1339
1400
  const plans = [];
1401
+ const relativeUi = importsBetweenUiAreRelative(ctx, config, mappings, pathOverride);
1340
1402
  for (const item of items) for (const file of item.files) {
1341
1403
  const raw = ctx.registry.readFile(file.path);
1342
- const content = file.path.endsWith(".css") ? raw : rewriteImports(raw, config.aliases);
1404
+ const content = file.path.endsWith(".css") ? raw : rewriteImports(raw, config.aliases, { relativeUi: relativeUi && item.type === "ui" });
1343
1405
  const target = targetFor(item, file, config, mappings, ctx, pathOverride);
1344
1406
  const current = ctx.fs.read(target);
1345
1407
  plans.push({
@@ -2160,6 +2222,42 @@ async function dispatch(ctx, command, rest, values, common) {
2160
2222
  }
2161
2223
  }
2162
2224
  }
2225
+ // src/cli/prompt.ts
2226
+ /**
2227
+ * The yes/no question of the executable, kept apart from `bin.ts` (which runs on import) so a test
2228
+ * can drive it with in-memory streams.
2229
+ * @packageDocumentation
2230
+ */
2231
+ /**
2232
+ * Asks a yes/no question and resolves `true` only for `y` or `yes` (any case). An empty answer is
2233
+ * no, and so is the end of input (Ctrl-D, a closed pipe): readline never settles `question()` when
2234
+ * its input ends, which left Node to exit with code 13 ("unsettled top-level await").
2235
+ *
2236
+ * @param question The question, without the `(y/N)` suffix.
2237
+ * @param input The stream the answer is read from.
2238
+ * @param output The stream the question is written to.
2239
+ * @returns Whether the user said yes.
2240
+ * @example
2241
+ * const yes = await askYesNo("Install packages?", process.stdin, process.stdout);
2242
+ * @since 1.0.0
2243
+ *
2244
+ * @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
2245
+ */
2246
+ async function askYesNo(question, input, output) {
2247
+ const prompt = createInterface({
2248
+ input,
2249
+ output
2250
+ });
2251
+ try {
2252
+ const answer = await new Promise((settle, fail) => {
2253
+ prompt.once("close", () => settle(""));
2254
+ prompt.question(`${question} (y/N) `).then(settle, fail);
2255
+ });
2256
+ return /^y(es)?$/i.test(answer.trim());
2257
+ } finally {
2258
+ prompt.close();
2259
+ }
2260
+ }
2163
2261
  // src/cli/registry-source.ts
2164
2262
  /**
2165
2263
  * Where the CLI reads the registry from. The registry ships inside the package, so nothing is
@@ -2243,21 +2341,12 @@ function realContext(cwd) {
2243
2341
  cwd: resolve(cwd ?? process.cwd()),
2244
2342
  fs: nodeFs(),
2245
2343
  registry: loadRegistrySource(),
2246
- version: "1.0.1",
2344
+ version: "1.0.2",
2247
2345
  out: (line) => process.stdout.write(`${line}\n`),
2248
2346
  err: (line) => process.stderr.write(`${line}\n`),
2249
2347
  async confirm(question) {
2250
2348
  if (!process.stdin.isTTY) return false;
2251
- const prompt = createInterface({
2252
- input: process.stdin,
2253
- output: process.stdout
2254
- });
2255
- try {
2256
- const answer = await prompt.question(`${question} (y/N) `);
2257
- return /^y(es)?$/i.test(answer.trim());
2258
- } finally {
2259
- prompt.close();
2260
- }
2349
+ return askYesNo(question, process.stdin, process.stdout);
2261
2350
  },
2262
2351
  install: ({ cwd: directory, manager, packages, output }) => new Promise((done, reject) => {
2263
2352
  const args = manager === "npm" ? ["install", ...packages] : ["add", ...packages];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadow-js/ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "shadcn-style components you add to your project with a CLI, styled with Tailwind v4",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,18 +60,18 @@
60
60
  "check": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:perf && npm run test:gc"
61
61
  },
62
62
  "peerDependencies": {
63
- "@shadow-js/core": "^1.0.1",
64
- "@shadow-js/primitives": "^1.0.1",
65
- "@shadow-js/web": "^1.0.1"
63
+ "@shadow-js/core": "^1.0.2",
64
+ "@shadow-js/primitives": "^1.0.2",
65
+ "@shadow-js/web": "^1.0.2"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@biomejs/biome": "2.5.15",
69
- "@shadow-js/compiler": "^1.0.1",
70
- "@shadow-js/core": "^1.0.1",
71
- "@shadow-js/forms": "^1.0.1",
72
- "@shadow-js/primitives": "^1.0.1",
73
- "@shadow-js/testing": "^1.0.1",
74
- "@shadow-js/web": "^1.0.1",
69
+ "@shadow-js/compiler": "^1.0.2",
70
+ "@shadow-js/core": "^1.0.2",
71
+ "@shadow-js/forms": "^1.0.2",
72
+ "@shadow-js/primitives": "^1.0.2",
73
+ "@shadow-js/testing": "^1.0.2",
74
+ "@shadow-js/web": "^1.0.2",
75
75
  "@tailwindcss/node": "4.3.3",
76
76
  "@types/node": "^24.0.0",
77
77
  "fast-check": "^4.10.2",
@@ -84,7 +84,7 @@
84
84
  "vitest": "5.0.3"
85
85
  },
86
86
  "dependencies": {
87
- "@shadow-js/table": "1.0.1",
87
+ "@shadow-js/table": "1.0.2",
88
88
  "class-variance-authority": "0.7.1",
89
89
  "clsx": "2.1.1",
90
90
  "tailwind-merge": "3.7.0"
@@ -1,7 +1,9 @@
1
1
  import { onCleanup, onMount, splitProps, useEffect, useSignal } from "@shadow-js/core";
2
2
  import {
3
+ callHandler,
3
4
  createPartContext,
4
5
  type Direction,
6
+ mergeElementProps,
5
7
  useDirectionAround,
6
8
  useRefSignal,
7
9
  } from "@shadow-js/primitives";
@@ -99,6 +101,7 @@ interface CarouselContextValue {
99
101
  readonly setViewport: (element: HTMLElement | undefined) => void;
100
102
  readonly claimSlide: () => number;
101
103
  readonly registerCount: () => void;
104
+ readonly releaseSlide: () => void;
102
105
  readonly pause: (paused: boolean) => void;
103
106
  readonly labels: () => ResolvedLabels;
104
107
  /** True once the carousel is mounted in the browser; the slide names wait for it (see CarouselItem). */
@@ -306,6 +309,12 @@ function Carousel(props: CarouselProps): JSX.Element {
306
309
  const [ready, setReady] = useSignal(false);
307
310
  let viewport: HTMLElement | undefined;
308
311
  let claimed = 0;
312
+ // The slide count the "reInit" listeners were last told, and whether the carousel is gone.
313
+ let reported = 0;
314
+ let disposed = false;
315
+ onCleanup(() => {
316
+ disposed = true;
317
+ });
309
318
  // The slide a programmatic move is heading for, until the scroll arrives there. While it is set,
310
319
  // the intermediate positions of a smooth scroll do not move the index back.
311
320
  let pending: number | undefined;
@@ -376,6 +385,27 @@ function Carousel(props: CarouselProps): JSX.Element {
376
385
  if (typeof viewport.scrollTo !== "function") viewport.scrollLeft = position * sign;
377
386
  }
378
387
  };
388
+ /** Tells the "reInit" listeners when the slide count differs from what they last heard. */
389
+ const announceCount = (): void => {
390
+ if (!ready()) return;
391
+ const total = count();
392
+ if (total === reported) return;
393
+ reported = total;
394
+ // Slides were removed: the current one may be gone.
395
+ if (total > 0 && index() > total - 1) setIndex(total - 1);
396
+ emit("reInit");
397
+ };
398
+ // A list that is replaced removes every slide and adds new ones in the same turn, and a carousel
399
+ // that unmounts removes them all: wait for the final count before telling anyone, once.
400
+ let announcing = false;
401
+ const scheduleAnnounce = (): void => {
402
+ if (!ready() || announcing) return;
403
+ announcing = true;
404
+ queueMicrotask(() => {
405
+ announcing = false;
406
+ if (!disposed) announceCount();
407
+ });
408
+ };
379
409
  const canPrevious = (): boolean => loops() || index() > 0;
380
410
  const canNext = (): boolean => loops() || index() < count() - 1;
381
411
 
@@ -402,9 +432,13 @@ function Carousel(props: CarouselProps): JSX.Element {
402
432
  return slot;
403
433
  },
404
434
  registerCount() {
405
- const changed = claimed !== count();
406
435
  setCount(claimed);
407
- if (changed && ready()) emit("reInit");
436
+ scheduleAnnounce();
437
+ },
438
+ releaseSlide() {
439
+ claimed = Math.max(0, claimed - 1);
440
+ setCount(claimed);
441
+ scheduleAnnounce();
408
442
  },
409
443
  pause: (value) => setPaused(value),
410
444
  labels,
@@ -436,6 +470,7 @@ function Carousel(props: CarouselProps): JSX.Element {
436
470
  onMount(() => {
437
471
  setRegionElement(regionNode);
438
472
  setReady(true);
473
+ reported = count();
439
474
  const start = opts().startIndex;
440
475
  if (start !== undefined && start > 0) scrollToIndex(start, true);
441
476
  (local.setApi as ((api: CarouselApi) => void) | undefined)?.(api);
@@ -445,7 +480,8 @@ function Carousel(props: CarouselProps): JSX.Element {
445
480
  // `autoplay` prop: changing it retimes the interval, and removing it stops the interval.
446
481
  useEffect(() => {
447
482
  const interval = local.autoplay as number | undefined;
448
- if (interval === undefined || interval <= 0) return undefined;
483
+ // NaN and Infinity must not reach setInterval: a NaN delay fires every tick and an Infinity one wraps to 0.
484
+ if (interval === undefined || !Number.isFinite(interval) || interval <= 0) return undefined;
449
485
  if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)
450
486
  return undefined;
451
487
  const timer = setInterval(() => {
@@ -489,12 +525,24 @@ function Carousel(props: CarouselProps): JSX.Element {
489
525
  class={cn("relative", local.class)}
490
526
  onKeyDown={(event: KeyboardEvent) => {
491
527
  onKeyDown(event);
492
- (local.onKeyDown as ((event: KeyboardEvent) => void) | undefined)?.(event);
528
+ callHandler(local.onKeyDown, event);
529
+ }}
530
+ onPointerEnter={(event: PointerEvent) => {
531
+ callHandler(local.onPointerEnter, event);
532
+ setPaused(true);
533
+ }}
534
+ onPointerLeave={(event: PointerEvent) => {
535
+ callHandler(local.onPointerLeave, event);
536
+ setPaused(false);
537
+ }}
538
+ onFocusIn={(event: FocusEvent) => {
539
+ callHandler(local.onFocusIn, event);
540
+ setPaused(true);
541
+ }}
542
+ onFocusOut={(event: FocusEvent) => {
543
+ callHandler(local.onFocusOut, event);
544
+ setPaused(false);
493
545
  }}
494
- onPointerEnter={() => setPaused(true)}
495
- onPointerLeave={() => setPaused(false)}
496
- onFocusIn={() => setPaused(true)}
497
- onFocusOut={() => setPaused(false)}
498
546
  {...rest}
499
547
  >
500
548
  {local.children as JSX.Element}
@@ -551,15 +599,15 @@ function CarouselContent(props: ComponentProps<"div">): JSX.Element {
551
599
  local.class,
552
600
  )}
553
601
  onScroll={(event: Event) => {
554
- (local.onScroll as ((event: Event) => void) | undefined)?.(event);
602
+ callHandler(local.onScroll, event);
555
603
  carousel.scrolled();
556
604
  }}
557
605
  onWheel={(event: WheelEvent) => {
558
- (local.onWheel as ((event: WheelEvent) => void) | undefined)?.(event);
606
+ callHandler(local.onWheel, event);
559
607
  carousel.userMoved();
560
608
  }}
561
609
  onPointerDown={(event: PointerEvent) => {
562
- (local.onPointerDown as ((event: PointerEvent) => void) | undefined)?.(event);
610
+ callHandler(local.onPointerDown, event);
563
611
  carousel.userMoved();
564
612
  if (event.button !== 0 || event.pointerType === "touch") return;
565
613
  const element = event.currentTarget as HTMLElement;
@@ -630,12 +678,28 @@ function CarouselItem(props: ComponentProps<"div">): JSX.Element {
630
678
  // before the next one is counted; so a slide is named only once the carousel is mounted, and
631
679
  // the name then uses the final count (UI-CRS-18).
632
680
  carousel.registerCount();
681
+ // A slide that goes away (a list that shrinks or is replaced) no longer counts.
682
+ onCleanup(() => carousel.releaseSlide());
683
+ let node: HTMLElement | undefined;
684
+ // The position among the slides that exist now, from the DOM order: the slot a slide claimed when
685
+ // it rendered is stale once another slide has been removed or inserted before it.
686
+ const position = (): number => {
687
+ carousel.count();
688
+ const parent = node?.parentElement;
689
+ if (node === undefined || parent === null || parent === undefined) return slot + 1;
690
+ const slides = Array.from(parent.children).filter(
691
+ (child) => child.getAttribute("data-slot") === "carousel-item",
692
+ );
693
+ const at = slides.indexOf(node);
694
+ return at === -1 ? slot + 1 : at + 1;
695
+ };
696
+ const merged = mergeElementProps({ ref: (element: HTMLElement) => (node = element) }, rest);
633
697
  return (
634
698
  <div
635
699
  role="group"
636
700
  aria-roledescription="slide"
637
701
  aria-label={
638
- carousel.ready() ? carousel.labels().slide(slot + 1, carousel.count()) : undefined
702
+ carousel.ready() ? carousel.labels().slide(position(), carousel.count()) : undefined
639
703
  }
640
704
  data-slot="carousel-item"
641
705
  class={cn(
@@ -643,7 +707,7 @@ function CarouselItem(props: ComponentProps<"div">): JSX.Element {
643
707
  carousel.orientation() === "vertical" ? "pt-4" : "ps-4",
644
708
  local.class,
645
709
  )}
646
- {...rest}
710
+ {...merged}
647
711
  />
648
712
  );
649
713
  }
@@ -699,7 +763,7 @@ function CarouselPrevious(props: ComponentProps<"button">): JSX.Element {
699
763
  )}
700
764
  disabled={!carousel.canPrevious()}
701
765
  onClick={(event: MouseEvent) => {
702
- (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
766
+ callHandler(local.onClick, event);
703
767
  carousel.previous();
704
768
  }}
705
769
  {...rest}
@@ -764,7 +828,7 @@ function CarouselNext(props: ComponentProps<"button">): JSX.Element {
764
828
  )}
765
829
  disabled={!carousel.canNext()}
766
830
  onClick={(event: MouseEvent) => {
767
- (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
831
+ callHandler(local.onClick, event);
768
832
  carousel.next();
769
833
  }}
770
834
  {...rest}
@@ -220,7 +220,11 @@ function ChartContainer(props: ChartContainerProps): JSX.Element {
220
220
  "children",
221
221
  ]);
222
222
  const uiLocale = useUiLocale();
223
- const [width, setWidth] = useSignal<number>((local.initialWidth as number | undefined) ?? 600);
223
+ // A NaN or Infinity width would put "NaN" into every path; fall back to the default instead.
224
+ const initial = local.initialWidth as number | undefined;
225
+ const [width, setWidth] = useSignal<number>(
226
+ initial !== undefined && Number.isFinite(initial) ? initial : 600,
227
+ );
224
228
  const [element, setElement] = useRefSignal<HTMLElement>();
225
229
  let node: HTMLElement | undefined;
226
230
  const dir = useDirectionAround(() => local.dir as Direction | undefined, element);
@@ -1,4 +1,5 @@
1
1
  import { splitProps } from "@shadow-js/core";
2
+ import { callHandler } from "@shadow-js/primitives";
2
3
  import type { ComponentProps, JSX } from "@shadow-js/web";
3
4
  import { cva, type VariantProps } from "class-variance-authority";
4
5
  import { cn } from "@/lib/utils";
@@ -117,7 +118,7 @@ function InputGroupAddon(props: InputGroupAddonProps): JSX.Element {
117
118
  const [local, rest] = splitProps(props, ["class", "align", "onClick"]);
118
119
  // The application's own click handler runs first; the focus move is the default.
119
120
  const onClick = (event: MouseEvent): void => {
120
- (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
121
+ callHandler(local.onClick, event);
121
122
  const target = event.target;
122
123
  if (target instanceof Element && target.closest("button") !== null) return;
123
124
  const group = (event.currentTarget as HTMLElement).closest('[data-slot="input-group"]');
@@ -1,4 +1,4 @@
1
- import { onMount, splitProps, useSignal } from "@shadow-js/core";
1
+ import { onCleanup, onMount, splitProps, useSignal } from "@shadow-js/core";
2
2
  import {
3
3
  createPartContext,
4
4
  type Direction,
@@ -51,6 +51,18 @@ export function resizeSizes(
51
51
  return next;
52
52
  }
53
53
 
54
+ /** One panel as the group tracks it. @internal */
55
+ interface PanelEntry {
56
+ readonly defaultSize: number;
57
+ readonly minSize: number;
58
+ readonly maxSize: number;
59
+ /** Set once a handle, a restored layout or a change of panels gave the panel a size; until then `defaultSize` applies. */
60
+ size: number | undefined;
61
+ element: HTMLElement | undefined;
62
+ /** Registered after the group had mounted: it still has to find its place among the panels that were there. */
63
+ late: boolean;
64
+ }
65
+
54
66
  /** What the group shares with its panels and handles. @internal */
55
67
  interface ResizableContextValue {
56
68
  readonly direction: () => "horizontal" | "vertical";
@@ -58,7 +70,18 @@ interface ResizableContextValue {
58
70
  readonly sizes: () => number[];
59
71
  readonly min: () => number[];
60
72
  readonly max: () => number[];
61
- readonly claimPanel: (defaultSize: number, minSize: number, maxSize: number) => number;
73
+ readonly claimPanel: (defaultSize: number, minSize: number, maxSize: number) => PanelEntry;
74
+ /** The place of a panel among the panels now, in document order. Reactive. */
75
+ readonly indexOf: (panel: PanelEntry) => number;
76
+ /** Called when a panel mounted: a panel that arrived late is put in its place and given its share. */
77
+ readonly placePanel: (panel: PanelEntry) => void;
78
+ /** Called when a panel unmounted: its share goes back to the others. */
79
+ readonly releasePanel: (panel: PanelEntry) => void;
80
+ /**
81
+ * The panel before a handle, as an index. Before the handle is in the document that is the number of panels rendered so far
82
+ * minus one (`fallback`); after, it is read from the document, so a panel that came or went moves the handle with it. Reactive.
83
+ */
84
+ readonly handleIndex: (handle: HTMLElement | undefined, fallback: number) => number;
62
85
  readonly move: (index: number, delta: number) => void;
63
86
  readonly set: (index: number, size: number) => void;
64
87
  /** How many panels have rendered so far: a handle sits after panel `n - 1`, so its index is `n - 1`. */
@@ -66,6 +89,43 @@ interface ResizableContextValue {
66
89
  readonly storageKey: () => string | undefined;
67
90
  }
68
91
 
92
+ const PRECEDING = 2;
93
+ const FOLLOWING = 4;
94
+
95
+ /**
96
+ * Brings sizes to what the group can show: scaled so they sum to 100, then each held within its panel's bounds with the
97
+ * difference spread over the panels that still have room. Best effort when the bounds cannot all be met (their minimums
98
+ * already add up to more than 100).
99
+ */
100
+ function fitSizes(
101
+ sizes: readonly number[],
102
+ min: readonly number[],
103
+ max: readonly number[],
104
+ ): number[] {
105
+ const total = sizes.reduce((sum, size) => sum + size, 0);
106
+ if (!(total > 0)) return [...sizes];
107
+ const out = sizes.map((size) => (size * 100) / total);
108
+ const clampAll = (): void => {
109
+ for (let at = 0; at < out.length; at += 1) {
110
+ out[at] = Math.min(max[at] ?? 100, Math.max(min[at] ?? 0, out[at] ?? 0));
111
+ }
112
+ };
113
+ for (let pass = 0; pass <= out.length; pass += 1) {
114
+ clampAll();
115
+ const gap = 100 - out.reduce((sum, size) => sum + size, 0);
116
+ if (Math.abs(gap) < 1e-9) break;
117
+ const roomy: number[] = [];
118
+ for (let at = 0; at < out.length; at += 1) {
119
+ const size = out[at] ?? 0;
120
+ if (gap > 0 ? size < (max[at] ?? 100) : size > (min[at] ?? 0)) roomy.push(at);
121
+ }
122
+ if (roomy.length === 0) break;
123
+ for (const at of roomy) out[at] = (out[at] ?? 0) + gap / roomy.length;
124
+ }
125
+ clampAll();
126
+ return out;
127
+ }
128
+
69
129
  /**
70
130
  * Reports a storage problem. The layout keeps working without storage; the warning tells the
71
131
  * developer why the sizes are not remembered.
@@ -152,24 +212,48 @@ function ResizablePanelGroup(props: ResizablePanelGroupProps): JSX.Element {
152
212
  const [groupElement, setGroupElement] = useRefSignal<HTMLElement>();
153
213
  let groupNode: HTMLElement | undefined;
154
214
  const dir = useDirectionAround(() => local.dir as Direction | undefined, groupElement);
155
- // The panels register their defaults while they render (plain arrays, no signal writes during a
156
- // render). The sizes signal starts empty and is filled by the first change or the restore on mount.
157
- const defaults: number[] = [];
158
- const minimums: number[] = [];
159
- const maximums: number[] = [];
160
- const [sizes, setSizes] = useSignal<number[]>([]);
215
+ // The panels register while they render (plain array, no signal writes during a render), in document order. A panel that
216
+ // arrives or leaves later changes the array and bumps `version`, which everything that reads the sizes or places follows.
217
+ const panels: PanelEntry[] = [];
218
+ const [version, setVersion] = useSignal(0);
219
+ const touch = (): void => {
220
+ setVersion((count) => count + 1);
221
+ };
222
+ let mounted = false;
223
+ let disposed = false;
224
+ onCleanup(() => {
225
+ disposed = true;
226
+ });
161
227
  const currentSizes = (): number[] => {
162
- const current = sizes();
163
- return current.length > 0 ? current : [...defaults];
228
+ version();
229
+ return panels.map((panel) => panel.size ?? panel.defaultSize);
230
+ };
231
+ const minimums = (): number[] => {
232
+ version();
233
+ return panels.map((panel) => panel.minSize);
234
+ };
235
+ const maximums = (): number[] => {
236
+ version();
237
+ return panels.map((panel) => panel.maxSize);
238
+ };
239
+ const apply = (sizes: readonly number[]): void => {
240
+ panels.forEach((panel, at) => {
241
+ panel.size = sizes[at];
242
+ });
243
+ touch();
244
+ };
245
+ /** The sizes the panels have now, fitted to 100 and to their bounds: what a change of the panels leaves behind. */
246
+ const refit = (): void => {
247
+ if (panels.length === 0) return;
248
+ apply(fitSizes(currentSizes(), minimums(), maximums()));
164
249
  };
165
- let claimed = 0;
166
250
  const storageKey = (): string | undefined =>
167
251
  local.autoSaveId === undefined
168
252
  ? undefined
169
253
  : `shadow-ui:resizable:${local.autoSaveId as string}`;
170
254
 
171
255
  const commit = (next: number[]): void => {
172
- setSizes(next);
256
+ apply(next);
173
257
  (local.onLayout as ((sizes: number[]) => void) | undefined)?.(next);
174
258
  const key = storageKey();
175
259
  if (key === undefined) return;
@@ -183,6 +267,7 @@ function ResizablePanelGroup(props: ResizablePanelGroupProps): JSX.Element {
183
267
 
184
268
  // Restores the remembered sizes after mount, so the server and the first client render agree.
185
269
  onMount(() => {
270
+ mounted = true;
186
271
  setGroupElement(groupNode);
187
272
  const key = storageKey();
188
273
  if (key === undefined) return;
@@ -190,10 +275,11 @@ function ResizablePanelGroup(props: ResizablePanelGroupProps): JSX.Element {
190
275
  const saved: unknown = JSON.parse(localStorage.getItem(key) ?? "null");
191
276
  if (
192
277
  Array.isArray(saved) &&
193
- saved.length === defaults.length &&
194
- saved.every((value) => typeof value === "number")
278
+ saved.length === panels.length &&
279
+ saved.every((value) => typeof value === "number" && Number.isFinite(value) && value >= 0)
195
280
  ) {
196
- setSizes(saved as number[]);
281
+ // Whatever is stored (edited by hand, saved by an older layout) is made to fit the bounds and the total.
282
+ apply(fitSizes(saved as number[], minimums(), maximums()));
197
283
  }
198
284
  } catch (error) {
199
285
  // A value that is not JSON is ignored and the defaults apply; the mismatch is reported.
@@ -205,25 +291,65 @@ function ResizablePanelGroup(props: ResizablePanelGroupProps): JSX.Element {
205
291
  direction,
206
292
  dir,
207
293
  sizes: currentSizes,
208
- min: () => minimums,
209
- max: () => maximums,
294
+ min: minimums,
295
+ max: maximums,
210
296
  claimPanel(defaultSize, minSize, maxSize) {
211
- const index = claimed;
212
- claimed += 1;
213
- defaults.push(defaultSize);
214
- minimums.push(minSize);
215
- maximums.push(maxSize);
216
- return index;
297
+ const panel: PanelEntry = {
298
+ defaultSize,
299
+ minSize,
300
+ maxSize,
301
+ size: undefined,
302
+ element: undefined,
303
+ late: mounted,
304
+ };
305
+ panels.push(panel);
306
+ return panel;
307
+ },
308
+ indexOf(panel) {
309
+ version();
310
+ return panels.indexOf(panel);
311
+ },
312
+ placePanel(panel) {
313
+ if (!panel.late || disposed) return;
314
+ panel.late = false;
315
+ // It was appended when it rendered; the document says where it really is.
316
+ panels.sort((first, second) => {
317
+ if (first.element === undefined || second.element === undefined) return 0;
318
+ return (first.element.compareDocumentPosition(second.element) & FOLLOWING) !== 0 ? -1 : 1;
319
+ });
320
+ refit();
321
+ },
322
+ releasePanel(panel) {
323
+ const at = panels.indexOf(panel);
324
+ if (at === -1) return;
325
+ panels.splice(at, 1);
326
+ if (disposed) return;
327
+ if (panels.length === 0) touch();
328
+ else refit();
329
+ },
330
+ handleIndex(handle, fallback) {
331
+ version();
332
+ if (handle === undefined || !mounted) return fallback;
333
+ let before = 0;
334
+ for (const panel of panels) {
335
+ if (
336
+ panel.element !== undefined &&
337
+ (handle.compareDocumentPosition(panel.element) & PRECEDING) !== 0
338
+ ) {
339
+ before += 1;
340
+ }
341
+ }
342
+ return Math.max(0, before - 1);
217
343
  },
218
344
  move(index, delta) {
219
- commit(resizeSizes(currentSizes(), index, delta, minimums, maximums));
345
+ commit(resizeSizes(currentSizes(), index, delta, minimums(), maximums()));
220
346
  },
221
347
  set(index, size) {
222
348
  const current = currentSizes();
223
349
  const delta = size - (current[index] ?? 0);
224
- commit(resizeSizes(current, index, delta, minimums, maximums));
350
+ commit(resizeSizes(current, index, delta, minimums(), maximums()));
225
351
  },
226
- panelsClaimed: () => defaults.length,
352
+ panelsClaimed: () => panels.length,
227
353
  storageKey,
228
354
  };
229
355
 
@@ -301,16 +427,22 @@ function ResizablePanel(props: ResizablePanelProps): JSX.Element {
301
427
  "maxSize",
302
428
  "children",
303
429
  ]);
304
- const index = group.claimPanel(
430
+ const panel = group.claimPanel(
305
431
  (local.defaultSize as number | undefined) ?? 50,
306
432
  (local.minSize as number | undefined) ?? 10,
307
433
  (local.maxSize as number | undefined) ?? 100,
308
434
  );
435
+ const index = (): number => group.indexOf(panel);
436
+ onMount(() => group.placePanel(panel));
437
+ onCleanup(() => group.releasePanel(panel));
309
438
  return (
310
439
  <div
440
+ ref={(node: HTMLElement) => {
441
+ panel.element = node;
442
+ }}
311
443
  data-slot="resizable-panel"
312
- data-panel-index={index}
313
- style={{ flex: `${group.sizes()[index] ?? 0} 1 0%`, minWidth: 0, minHeight: 0 }}
444
+ data-panel-index={index()}
445
+ style={{ flex: `${group.sizes()[index()] ?? 0} 1 0%`, minWidth: 0, minHeight: 0 }}
314
446
  class={cn("min-h-0 min-w-0 overflow-hidden", local.class)}
315
447
  {...rest}
316
448
  >
@@ -362,11 +494,15 @@ function ResizableHandle(props: ResizableHandleProps): JSX.Element {
362
494
  const group = resizableContext.use("ResizableHandle");
363
495
  const t = useUiStrings();
364
496
  const [local, rest] = splitProps(props, ["class", "withHandle"]);
365
- // A handle sits right after the panel before it, so its index follows from the panels rendered.
366
- const handleIndex = Math.max(0, group.panelsClaimed() - 1);
497
+ // A handle sits right after the panel before it: from the panels rendered so far, then from the document once it is there.
498
+ const fallbackIndex = Math.max(0, group.panelsClaimed() - 1);
499
+ let handleNode: HTMLElement | undefined;
500
+ const handleIndexNow = (): number => group.handleIndex(handleNode, fallbackIndex);
367
501
  const step = 5;
368
502
  const vertical = (): boolean => group.direction() === "vertical";
369
503
  let stopDrag: (() => void) | undefined;
504
+ // A handle that unmounts mid-drag (a panel closes) must not keep resizing from window listeners.
505
+ onCleanup(() => stopDrag?.());
370
506
 
371
507
  const startDrag = (event: PointerEvent): void => {
372
508
  if (event.button !== 0) return;
@@ -378,6 +514,7 @@ function ResizableHandle(props: ResizableHandleProps): JSX.Element {
378
514
  const box = (event.currentTarget as HTMLElement | null)?.parentElement?.getBoundingClientRect();
379
515
  const length = box === undefined ? 0 : vertical() ? box.height : box.width;
380
516
  const mirror = !vertical() && group.dir() === "rtl" ? -1 : 1;
517
+ const handleIndex = handleIndexNow();
381
518
  const startSize = group.sizes()[handleIndex] ?? 0;
382
519
  // Only the pointer that pressed the handle moves it; another finger is not part of this drag.
383
520
  const pointer = event.pointerId;
@@ -402,12 +539,15 @@ function ResizableHandle(props: ResizableHandleProps): JSX.Element {
402
539
 
403
540
  return (
404
541
  <div
542
+ ref={(node: HTMLElement) => {
543
+ handleNode = node;
544
+ }}
405
545
  role="separator"
406
546
  tabindex={0}
407
547
  aria-orientation={vertical() ? "horizontal" : "vertical"}
408
- aria-valuenow={Math.round(group.sizes()[handleIndex] ?? 0)}
409
- aria-valuemin={Math.round(group.min()[handleIndex] ?? 0)}
410
- aria-valuemax={Math.round(group.max()[handleIndex] ?? 100)}
548
+ aria-valuenow={Math.round(group.sizes()[handleIndexNow()] ?? 0)}
549
+ aria-valuemin={Math.round(group.min()[handleIndexNow()] ?? 0)}
550
+ aria-valuemax={Math.round(group.max()[handleIndexNow()] ?? 100)}
411
551
  aria-label={t("resizable.handle", "Resize panels")}
412
552
  data-slot="resizable-handle"
413
553
  data-orientation={vertical() ? "vertical" : "horizontal"}
@@ -421,6 +561,7 @@ function ResizableHandle(props: ResizableHandleProps): JSX.Element {
421
561
  )}
422
562
  onPointerDown={(event: PointerEvent) => startDrag(event)}
423
563
  onKeyDown={(event: KeyboardEvent) => {
564
+ const handleIndex = handleIndexNow();
424
565
  const forward = vertical()
425
566
  ? "ArrowDown"
426
567
  : group.dir() === "rtl"
package/registry.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadow-js/ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "frameworkDependencies": [
5
5
  "@shadow-js/core",
6
6
  "@shadow-js/primitives",
@@ -587,7 +587,9 @@
587
587
  ],
588
588
  "primitives": [
589
589
  "Direction",
590
+ "callHandler",
590
591
  "createPartContext",
592
+ "mergeElementProps",
591
593
  "useDirectionAround",
592
594
  "useRefSignal"
593
595
  ],
@@ -1061,7 +1063,7 @@
1061
1063
  "utils"
1062
1064
  ],
1063
1065
  "dependencies": {
1064
- "@shadow-js/table": "1.0.1"
1066
+ "@shadow-js/table": "1.0.2"
1065
1067
  },
1066
1068
  "exports": [
1067
1069
  "DataTable",
@@ -1663,7 +1665,9 @@
1663
1665
  "InputGroupText",
1664
1666
  "InputGroupTextarea"
1665
1667
  ],
1666
- "primitives": [],
1668
+ "primitives": [
1669
+ "callHandler"
1670
+ ],
1667
1671
  "tailwind": {
1668
1672
  "tokens": [
1669
1673
  "accent",