@protolabsai/ui 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2515,6 +2515,56 @@ a.pl-changelog__version:hover {
2515
2515
  outline: none;
2516
2516
  }
2517
2517
 
2518
+ /* Reopen handle — the grab strip a collapsed side leaves at its edge. Drag it in to
2519
+ size the panel open (VS Code style), or tap/Enter to pop it to its min width. */
2520
+ .pl-appshell__reopen {
2521
+ position: relative;
2522
+ flex: 0 0 6px;
2523
+ align-self: stretch;
2524
+ padding: 0;
2525
+ background: transparent;
2526
+ border: none;
2527
+ cursor: col-resize;
2528
+ touch-action: none;
2529
+ transition: background var(--pl-motion-fast) var(--pl-motion-ease);
2530
+ }
2531
+ .pl-appshell__reopen--right {
2532
+ border-left: var(--pl-border-width) solid var(--pl-color-border);
2533
+ }
2534
+ .pl-appshell__reopen--left {
2535
+ border-right: var(--pl-border-width) solid var(--pl-color-border);
2536
+ }
2537
+ .pl-appshell__reopen:hover,
2538
+ .pl-appshell__reopen:focus-visible {
2539
+ background: var(--pl-color-bg-hover);
2540
+ outline: none;
2541
+ }
2542
+ /* a centered grip that surfaces on hover/focus so the affordance is discoverable */
2543
+ .pl-appshell__reopen::after {
2544
+ content: "";
2545
+ position: absolute;
2546
+ top: 50%;
2547
+ left: 50%;
2548
+ width: 2px;
2549
+ height: 24px;
2550
+ transform: translate(-50%, -50%);
2551
+ border-radius: 2px;
2552
+ background: var(--pl-color-border-strong);
2553
+ opacity: 0;
2554
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
2555
+ }
2556
+ .pl-appshell__reopen:hover::after,
2557
+ .pl-appshell__reopen:focus-visible::after {
2558
+ opacity: 1;
2559
+ }
2560
+
2561
+ /* While a divider/reopen gesture is live, kill text selection + force the resize
2562
+ cursor everywhere (the pointer roams the whole window during the drag). */
2563
+ .pl-appshell-frame--dragging {
2564
+ cursor: col-resize;
2565
+ user-select: none;
2566
+ }
2567
+
2518
2568
  .pl-appshell--mobile {
2519
2569
  flex-direction: column;
2520
2570
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/app-shell.tsx CHANGED
@@ -253,6 +253,11 @@ function useIsMobile(breakpoint: number) {
253
253
 
254
254
  type RailOrder = { left: string[]; right: string[] };
255
255
 
256
+ // An active divider/reopen gesture. `move`/`up` are the window listeners for this
257
+ // gesture (stored so they can be detached) — see the drag block in AppShell.
258
+ type DragKind = "divider" | "reopen-right" | "reopen-left";
259
+ type DragSession = { kind: DragKind; move: (e: PointerEvent) => void; up: (e: PointerEvent) => void };
260
+
256
261
  export type AppShellProps = {
257
262
  leftItems: RailItem[];
258
263
  rightItems: RailItem[];
@@ -331,70 +336,107 @@ export function AppShell({
331
336
  }: AppShellProps) {
332
337
  const isMobile = useIsMobile(mobileBreakpoint);
333
338
 
334
- // ── resize handle ──
335
- // One divider, zero-sum: the right column is width-controlled and the left
336
- // flexes to fill, so dragging grows one column as it shrinks the other. Each
337
- // side is held at its min on the way in; OVERDRAG px past that min snaps that
338
- // side closed (collapse is controlled the host restores it).
339
- const OVERDRAG = 64;
339
+ // ── divider + collapse/reopen drag ──
340
+ // One divider, zero-sum: the right column is width-controlled and the left flexes,
341
+ // so dragging grows one column as it shrinks the other. The gesture runs on WINDOW
342
+ // listeners so it survives the layout change when a side closes collapse is only
343
+ // COMMITTED on pointer-up (drag back out before releasing to recover an accidental
344
+ // close), the panel can shrink past its min toward the edge, and a collapsed side
345
+ // exposes a reopen handle you can drag (or tap) back open. Collapse fires once the
346
+ // side is dragged below half its min — you have to bring it well in, not just nudge.
347
+ const COLLAPSE_RIGHT = Math.round(minRightWidth * 0.5);
348
+ const COLLAPSE_LEFT = Math.round(minLeftWidth * 0.5);
349
+ const REOPEN_TAP = 6; // movement under this on a reopen handle counts as a tap (open at min)
340
350
  const leftColRef = useRef<HTMLElement | null>(null);
341
- const drag = useRef<{ startX: number; startW: number; startLeftW: number } | null>(null);
342
- /** Resize `rightWidth` to `raw` px, holding both columns at their minimums. */
343
- const commit = useCallback(
344
- (raw: number, startLeftW: number, startW: number) => {
345
- const upper = Math.max(minRightWidth, Math.min(maxRightWidth, startLeftW + startW - minLeftWidth));
346
- onRightWidthChange(Math.min(upper, Math.max(minRightWidth, raw)));
351
+ const drag = useRef<DragSession | null>(null);
352
+ const [dragging, setDragging] = useState(false);
353
+
354
+ /** rightWidth snapped back into the valid open range (left keeps its min). */
355
+ const clampOpen = useCallback(
356
+ (w: number, spanW: number) => {
357
+ const upper = Math.max(minRightWidth, Math.min(maxRightWidth, spanW - minLeftWidth));
358
+ return Math.min(upper, Math.max(minRightWidth, w));
347
359
  },
348
- [minRightWidth, maxRightWidth, minLeftWidth, onRightWidthChange],
360
+ [minRightWidth, maxRightWidth, minLeftWidth],
349
361
  );
350
- const onPointerDown = useCallback(
351
- (e: ReactPointerEvent<HTMLDivElement>) => {
362
+
363
+ const endGesture = useCallback(() => {
364
+ const d = drag.current;
365
+ if (d) {
366
+ window.removeEventListener("pointermove", d.move);
367
+ window.removeEventListener("pointerup", d.up);
368
+ window.removeEventListener("pointercancel", d.up);
369
+ }
370
+ drag.current = null;
371
+ setDragging(false);
372
+ }, []);
373
+
374
+ const beginDrag = useCallback(
375
+ (kind: DragKind, e: ReactPointerEvent<HTMLElement>) => {
352
376
  e.preventDefault();
353
- drag.current = { startX: e.clientX, startW: rightWidth, startLeftW: leftColRef.current?.clientWidth ?? 0 };
354
- e.currentTarget.setPointerCapture(e.pointerId);
355
- },
356
- [rightWidth],
357
- );
358
- const onPointerMove = useCallback(
359
- (e: ReactPointerEvent<HTMLDivElement>) => {
360
- const d = drag.current;
361
- if (!d) return;
362
- const raw = d.startW + (d.startX - e.clientX);
363
- const predictedLeft = d.startLeftW - (raw - d.startW);
364
- const endDrag = () => {
365
- drag.current = null;
366
- e.currentTarget.releasePointerCapture?.(e.pointerId);
367
- };
368
- // Overdrag toward the right rail → snap the right column closed.
369
- if (onCollapse && raw < minRightWidth - OVERDRAG) {
370
- endDrag();
371
- onCollapse(true);
372
- return;
373
- }
374
- // Overdrag toward the left rail → snap the left column closed.
375
- if (onLeftCollapse && predictedLeft < minLeftWidth - OVERDRAG) {
376
- endDrag();
377
- onLeftCollapse(true);
378
- return;
377
+ const leftW = leftColRef.current?.clientWidth ?? 0;
378
+ // Reopen reveals the side first so it can grow from the edge under the pointer.
379
+ const startW = kind === "reopen-right" ? 0 : rightWidth;
380
+ if (kind === "reopen-right") {
381
+ onCollapse?.(false);
382
+ onRightWidthChange(0);
383
+ } else if (kind === "reopen-left") {
384
+ onLeftCollapse?.(false);
379
385
  }
380
- commit(raw, d.startLeftW, d.startW);
386
+ const startX = e.clientX;
387
+ // The two columns share a fixed span during the gesture (zero-sum divider).
388
+ const spanW = startW + leftW;
389
+ const rightFromPointer = (clientX: number) =>
390
+ Math.max(0, Math.min(spanW || maxRightWidth, startW + (startX - clientX)));
391
+
392
+ const move = (ev: PointerEvent) => {
393
+ if (kind === "reopen-left") return; // left is the flex column — reopening is binary
394
+ onRightWidthChange(rightFromPointer(ev.clientX));
395
+ };
396
+ const up = (ev: PointerEvent) => {
397
+ if (kind === "reopen-left") {
398
+ endGesture();
399
+ return;
400
+ }
401
+ const moved = Math.abs(ev.clientX - startX);
402
+ const raw = rightFromPointer(ev.clientX);
403
+ const span = spanW || raw + minLeftWidth;
404
+ const leftAtRaw = span - raw;
405
+ if (kind === "reopen-right" && moved < REOPEN_TAP) {
406
+ onCollapse?.(false); // a tap on the reopen handle → just open at min
407
+ onRightWidthChange(clampOpen(minRightWidth, span));
408
+ } else if (onCollapse && raw < COLLAPSE_RIGHT) {
409
+ onCollapse(true);
410
+ onRightWidthChange(clampOpen(startW || minRightWidth, span)); // sane width for next open
411
+ } else if (onLeftCollapse && spanW > 0 && leftAtRaw < COLLAPSE_LEFT) {
412
+ onLeftCollapse(true);
413
+ onRightWidthChange(clampOpen(startW || minRightWidth, span)); // restore right for when left reopens
414
+ } else {
415
+ onCollapse?.(false);
416
+ onRightWidthChange(clampOpen(raw, span));
417
+ }
418
+ endGesture();
419
+ };
420
+
421
+ drag.current = { kind, move, up };
422
+ window.addEventListener("pointermove", move);
423
+ window.addEventListener("pointerup", up);
424
+ window.addEventListener("pointercancel", up);
425
+ setDragging(true);
381
426
  },
382
- [minRightWidth, minLeftWidth, onCollapse, onLeftCollapse, commit],
427
+ [rightWidth, minRightWidth, minLeftWidth, maxRightWidth, onRightWidthChange, onCollapse, onLeftCollapse, clampOpen, endGesture, COLLAPSE_RIGHT, COLLAPSE_LEFT],
383
428
  );
384
- const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
385
- drag.current = null;
386
- e.currentTarget.releasePointerCapture?.(e.pointerId);
387
- }, []);
429
+
388
430
  const onKeyDown = useCallback(
389
431
  (e: ReactKeyboardEvent<HTMLDivElement>) => {
390
432
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
391
433
  e.preventDefault();
392
434
  const step = e.shiftKey ? 48 : 16;
393
- const startLeftW = leftColRef.current?.clientWidth ?? 0;
435
+ const spanW = rightWidth + (leftColRef.current?.clientWidth ?? minLeftWidth);
394
436
  // ArrowLeft grows the right column (shrinks the left); ArrowRight reverses.
395
- commit(rightWidth + (e.key === "ArrowLeft" ? step : -step), startLeftW, rightWidth);
437
+ onRightWidthChange(clampOpen(rightWidth + (e.key === "ArrowLeft" ? step : -step), spanW));
396
438
  },
397
- [rightWidth, commit],
439
+ [rightWidth, onRightWidthChange, clampOpen],
398
440
  );
399
441
 
400
442
  // ── rail drag-and-drop (only when onRailReorder is provided) ──
@@ -493,11 +535,30 @@ export function AppShell({
493
535
  const ctxLeft = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("left", e, id) : undefined;
494
536
  const ctxRight = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("right", e, id) : undefined;
495
537
 
538
+ // Collapsed-but-present sides expose a reopen handle (drag in to size it open, or
539
+ // tap to pop it to min) — VS Code style.
540
+ const canReopenRight = !showRight && rightItems.length > 0 && showLeft;
541
+ const canReopenLeft = leftCollapsed && showRight;
542
+
496
543
  const renderShell = (leftRail: ReactNode, rightRail: ReactNode) => (
497
- <div className={cx("pl-appshell-frame", className)}>
544
+ <div className={cx("pl-appshell-frame", dragging && "pl-appshell-frame--dragging", className)}>
498
545
  {header != null && <div className="pl-appshell__header">{header}</div>}
499
546
  <div className="pl-appshell">
500
547
  {leftRail}
548
+ {canReopenLeft && (
549
+ <button
550
+ type="button"
551
+ className="pl-appshell__reopen pl-appshell__reopen--left"
552
+ aria-label="Open left panel"
553
+ onPointerDown={(e) => beginDrag("reopen-left", e)}
554
+ onKeyDown={(e) => {
555
+ if (e.key === "Enter" || e.key === " ") {
556
+ e.preventDefault();
557
+ onLeftCollapse?.(false);
558
+ }
559
+ }}
560
+ />
561
+ )}
501
562
  {showLeft && (
502
563
  <main ref={leftColRef} className="pl-appshell__col pl-appshell__col--left">
503
564
  {leftContent}
@@ -514,9 +575,7 @@ export function AppShell({
514
575
  aria-valuemax={maxRightWidth}
515
576
  aria-valuetext={`Right panel ${rightWidth}px`}
516
577
  tabIndex={0}
517
- onPointerDown={onPointerDown}
518
- onPointerMove={onPointerMove}
519
- onPointerUp={onPointerUp}
578
+ onPointerDown={(e) => beginDrag("divider", e)}
520
579
  onKeyDown={onKeyDown}
521
580
  onDoubleClick={() => onCollapse?.(true)}
522
581
  />
@@ -529,6 +588,20 @@ export function AppShell({
529
588
  {rightContent}
530
589
  </aside>
531
590
  )}
591
+ {canReopenRight && (
592
+ <button
593
+ type="button"
594
+ className="pl-appshell__reopen pl-appshell__reopen--right"
595
+ aria-label="Open right panel"
596
+ onPointerDown={(e) => beginDrag("reopen-right", e)}
597
+ onKeyDown={(e) => {
598
+ if (e.key === "Enter" || e.key === " ") {
599
+ e.preventDefault();
600
+ onCollapse?.(false);
601
+ }
602
+ }}
603
+ />
604
+ )}
532
605
  {rightRail}
533
606
  </div>
534
607
  {utilityBar != null && <div className="pl-appshell__utility">{utilityBar}</div>}
@@ -244,6 +244,56 @@
244
244
  outline: none;
245
245
  }
246
246
 
247
+ /* Reopen handle — the grab strip a collapsed side leaves at its edge. Drag it in to
248
+ size the panel open (VS Code style), or tap/Enter to pop it to its min width. */
249
+ .pl-appshell__reopen {
250
+ position: relative;
251
+ flex: 0 0 6px;
252
+ align-self: stretch;
253
+ padding: 0;
254
+ background: transparent;
255
+ border: none;
256
+ cursor: col-resize;
257
+ touch-action: none;
258
+ transition: background var(--pl-motion-fast) var(--pl-motion-ease);
259
+ }
260
+ .pl-appshell__reopen--right {
261
+ border-left: var(--pl-border-width) solid var(--pl-color-border);
262
+ }
263
+ .pl-appshell__reopen--left {
264
+ border-right: var(--pl-border-width) solid var(--pl-color-border);
265
+ }
266
+ .pl-appshell__reopen:hover,
267
+ .pl-appshell__reopen:focus-visible {
268
+ background: var(--pl-color-bg-hover);
269
+ outline: none;
270
+ }
271
+ /* a centered grip that surfaces on hover/focus so the affordance is discoverable */
272
+ .pl-appshell__reopen::after {
273
+ content: "";
274
+ position: absolute;
275
+ top: 50%;
276
+ left: 50%;
277
+ width: 2px;
278
+ height: 24px;
279
+ transform: translate(-50%, -50%);
280
+ border-radius: 2px;
281
+ background: var(--pl-color-border-strong);
282
+ opacity: 0;
283
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
284
+ }
285
+ .pl-appshell__reopen:hover::after,
286
+ .pl-appshell__reopen:focus-visible::after {
287
+ opacity: 1;
288
+ }
289
+
290
+ /* While a divider/reopen gesture is live, kill text selection + force the resize
291
+ cursor everywhere (the pointer roams the whole window during the drag). */
292
+ .pl-appshell-frame--dragging {
293
+ cursor: col-resize;
294
+ user-select: none;
295
+ }
296
+
247
297
  .pl-appshell--mobile {
248
298
  flex-direction: column;
249
299
  }