@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 +15 -0
- package/dist/cli.mjs +313 -224
- package/package.json +11 -11
- package/registry/carousel/carousel.tsx +79 -15
- package/registry/chart/chart.tsx +5 -1
- package/registry/input-group/input-group.tsx +2 -1
- package/registry/resizable/resizable.tsx +175 -34
- package/registry.json +7 -3
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 {
|
|
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
|
-
|
|
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
|
|
214
|
-
function
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
const
|
|
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
|
-
|
|
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]
|
|
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 =
|
|
258
|
-
const above =
|
|
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 =
|
|
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
|
-
|
|
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/"))
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
64
|
-
"@shadow-js/primitives": "^1.0.
|
|
65
|
-
"@shadow-js/web": "^1.0.
|
|
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.
|
|
70
|
-
"@shadow-js/core": "^1.0.
|
|
71
|
-
"@shadow-js/forms": "^1.0.
|
|
72
|
-
"@shadow-js/primitives": "^1.0.
|
|
73
|
-
"@shadow-js/testing": "^1.0.
|
|
74
|
-
"@shadow-js/web": "^1.0.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
602
|
+
callHandler(local.onScroll, event);
|
|
555
603
|
carousel.scrolled();
|
|
556
604
|
}}
|
|
557
605
|
onWheel={(event: WheelEvent) => {
|
|
558
|
-
(local.onWheel
|
|
606
|
+
callHandler(local.onWheel, event);
|
|
559
607
|
carousel.userMoved();
|
|
560
608
|
}}
|
|
561
609
|
onPointerDown={(event: PointerEvent) => {
|
|
562
|
-
(local.onPointerDown
|
|
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(
|
|
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
|
-
{...
|
|
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
|
|
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
|
|
831
|
+
callHandler(local.onClick, event);
|
|
768
832
|
carousel.next();
|
|
769
833
|
}}
|
|
770
834
|
{...rest}
|
package/registry/chart/chart.tsx
CHANGED
|
@@ -220,7 +220,11 @@ function ChartContainer(props: ChartContainerProps): JSX.Element {
|
|
|
220
220
|
"children",
|
|
221
221
|
]);
|
|
222
222
|
const uiLocale = useUiLocale();
|
|
223
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
return
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
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:
|
|
209
|
-
max:
|
|
294
|
+
min: minimums,
|
|
295
|
+
max: maximums,
|
|
210
296
|
claimPanel(defaultSize, minSize, maxSize) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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: () =>
|
|
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
|
|
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
|
|
366
|
-
const
|
|
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()[
|
|
409
|
-
aria-valuemin={Math.round(group.min()[
|
|
410
|
-
aria-valuemax={Math.round(group.max()[
|
|
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.
|
|
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.
|
|
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",
|