@shadow-js/ui 1.0.1 → 1.0.3

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.
@@ -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.3",
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.3"
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",