@nimblebrain/synapse 0.17.1 → 0.18.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.
@@ -30,7 +30,12 @@ interface BreadcrumbProps extends Omit<HTMLAttributes<HTMLElement>, "children">
30
30
  }
31
31
  declare function Breadcrumb({ crumbs, separator, style, ...rest }: BreadcrumbProps): react_jsx_runtime.JSX.Element;
32
32
 
33
- type Variant = "primary" | "secondary" | "ghost";
33
+ /**
34
+ * `danger` is for the verb that destroys something — Delete, Remove, Forget. It is
35
+ * filled like `primary`, so it is never the quiet option beside one, and red so the
36
+ * difference reads before the label does.
37
+ */
38
+ type Variant = "primary" | "secondary" | "ghost" | "danger";
34
39
  type Size = "sm" | "md";
35
40
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
36
41
  variant?: Variant;
@@ -55,6 +60,27 @@ interface CardProps extends HTMLAttributes<HTMLDivElement> {
55
60
  }
56
61
  declare function Card({ padding, interactive, style, className, children, ...rest }: CardProps): react_jsx_runtime.JSX.Element;
57
62
 
63
+ interface ConfirmDialogProps {
64
+ open: boolean;
65
+ /** Every dismissal path — Escape, the scrim, Cancel — calls this with `false`. */
66
+ onOpenChange: (open: boolean) => void;
67
+ /** Names the dialog (`aria-labelledby`). */
68
+ title: ReactNode;
69
+ /** One line under the title. Becomes the dialog's accessible description. */
70
+ description?: ReactNode;
71
+ /** What the action will do, and any control that shapes it. */
72
+ children?: ReactNode;
73
+ confirmLabel?: string;
74
+ cancelLabel?: string;
75
+ /** Confirm's label while `onConfirm` is in flight. Defaults to `confirmLabel`. */
76
+ pendingLabel?: string;
77
+ /** Render Confirm as a `danger` button, and open with focus on Cancel. */
78
+ destructive?: boolean;
79
+ /** Resolve to close. Throw to stay open with the error's message shown. */
80
+ onConfirm: () => void | Promise<void>;
81
+ }
82
+ declare function ConfirmDialog({ open, onOpenChange, title, description, children, confirmLabel, cancelLabel, pendingLabel, destructive, onConfirm, }: ConfirmDialogProps): react_jsx_runtime.JSX.Element | null;
83
+
58
84
  type Side$1 = "left" | "right" | "bottom";
59
85
  interface DrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
60
86
  open: boolean;
@@ -446,6 +472,7 @@ declare const tokens: {
446
472
  readonly borderStrong: "var(--color-border-secondary, #d1d5db)";
447
473
  readonly ring: "var(--color-ring-primary, #2563eb)";
448
474
  readonly danger: "var(--nb-color-danger, #dc2626)";
475
+ readonly dangerFg: "var(--nb-color-danger-foreground, #ffffff)";
449
476
  readonly success: "var(--nb-color-success, #059669)";
450
477
  readonly warning: "var(--nb-color-warning, #f59e0b)";
451
478
  readonly processing: "var(--nb-color-processing, #7c3aed)";
@@ -534,4 +561,4 @@ interface HeadingProps extends Omit<HTMLAttributes<HTMLHeadingElement>, "color">
534
561
  /** Display heading using the heading font slot. Defaults to md, semibold. */
535
562
  declare function Heading({ size, weight, tone, as: As, style, children, ...rest }: HeadingProps): react_jsx_runtime.JSX.Element;
536
563
 
537
- export { AppFrame, Avatar, Badge, type BadgeTone, Breadcrumb, Button, Card, type Column, type Crumb, Divider, Drawer, EmptyState, Heading, type HeadingSize, Inline, ListDetailLayout, ListRow, PageHeader, PageLayout, Pagination, Prose, SearchField, SegmentedControl, SidebarLayout, Spacer, Spinner, Stack, type Status, StatusDot, Table, Tabs, Text, TextLink, type TextSize, type Tokens, headingStyle, textStyle, tokens, useBreakpoint, useListDetail, useSidebar };
564
+ export { AppFrame, Avatar, Badge, type BadgeTone, Breadcrumb, Button, Card, type Column, ConfirmDialog, type Crumb, Divider, Drawer, EmptyState, Heading, type HeadingSize, Inline, ListDetailLayout, ListRow, PageHeader, PageLayout, Pagination, Prose, SearchField, SegmentedControl, SidebarLayout, Spacer, Spinner, Stack, type Status, StatusDot, Table, Tabs, Text, TextLink, type TextSize, type Tokens, headingStyle, textStyle, tokens, useBreakpoint, useListDetail, useSidebar };
@@ -30,7 +30,12 @@ interface BreadcrumbProps extends Omit<HTMLAttributes<HTMLElement>, "children">
30
30
  }
31
31
  declare function Breadcrumb({ crumbs, separator, style, ...rest }: BreadcrumbProps): react_jsx_runtime.JSX.Element;
32
32
 
33
- type Variant = "primary" | "secondary" | "ghost";
33
+ /**
34
+ * `danger` is for the verb that destroys something — Delete, Remove, Forget. It is
35
+ * filled like `primary`, so it is never the quiet option beside one, and red so the
36
+ * difference reads before the label does.
37
+ */
38
+ type Variant = "primary" | "secondary" | "ghost" | "danger";
34
39
  type Size = "sm" | "md";
35
40
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
36
41
  variant?: Variant;
@@ -55,6 +60,27 @@ interface CardProps extends HTMLAttributes<HTMLDivElement> {
55
60
  }
56
61
  declare function Card({ padding, interactive, style, className, children, ...rest }: CardProps): react_jsx_runtime.JSX.Element;
57
62
 
63
+ interface ConfirmDialogProps {
64
+ open: boolean;
65
+ /** Every dismissal path — Escape, the scrim, Cancel — calls this with `false`. */
66
+ onOpenChange: (open: boolean) => void;
67
+ /** Names the dialog (`aria-labelledby`). */
68
+ title: ReactNode;
69
+ /** One line under the title. Becomes the dialog's accessible description. */
70
+ description?: ReactNode;
71
+ /** What the action will do, and any control that shapes it. */
72
+ children?: ReactNode;
73
+ confirmLabel?: string;
74
+ cancelLabel?: string;
75
+ /** Confirm's label while `onConfirm` is in flight. Defaults to `confirmLabel`. */
76
+ pendingLabel?: string;
77
+ /** Render Confirm as a `danger` button, and open with focus on Cancel. */
78
+ destructive?: boolean;
79
+ /** Resolve to close. Throw to stay open with the error's message shown. */
80
+ onConfirm: () => void | Promise<void>;
81
+ }
82
+ declare function ConfirmDialog({ open, onOpenChange, title, description, children, confirmLabel, cancelLabel, pendingLabel, destructive, onConfirm, }: ConfirmDialogProps): react_jsx_runtime.JSX.Element | null;
83
+
58
84
  type Side$1 = "left" | "right" | "bottom";
59
85
  interface DrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
60
86
  open: boolean;
@@ -446,6 +472,7 @@ declare const tokens: {
446
472
  readonly borderStrong: "var(--color-border-secondary, #d1d5db)";
447
473
  readonly ring: "var(--color-ring-primary, #2563eb)";
448
474
  readonly danger: "var(--nb-color-danger, #dc2626)";
475
+ readonly dangerFg: "var(--nb-color-danger-foreground, #ffffff)";
449
476
  readonly success: "var(--nb-color-success, #059669)";
450
477
  readonly warning: "var(--nb-color-warning, #f59e0b)";
451
478
  readonly processing: "var(--nb-color-processing, #7c3aed)";
@@ -534,4 +561,4 @@ interface HeadingProps extends Omit<HTMLAttributes<HTMLHeadingElement>, "color">
534
561
  /** Display heading using the heading font slot. Defaults to md, semibold. */
535
562
  declare function Heading({ size, weight, tone, as: As, style, children, ...rest }: HeadingProps): react_jsx_runtime.JSX.Element;
536
563
 
537
- export { AppFrame, Avatar, Badge, type BadgeTone, Breadcrumb, Button, Card, type Column, type Crumb, Divider, Drawer, EmptyState, Heading, type HeadingSize, Inline, ListDetailLayout, ListRow, PageHeader, PageLayout, Pagination, Prose, SearchField, SegmentedControl, SidebarLayout, Spacer, Spinner, Stack, type Status, StatusDot, Table, Tabs, Text, TextLink, type TextSize, type Tokens, headingStyle, textStyle, tokens, useBreakpoint, useListDetail, useSidebar };
564
+ export { AppFrame, Avatar, Badge, type BadgeTone, Breadcrumb, Button, Card, type Column, ConfirmDialog, type Crumb, Divider, Drawer, EmptyState, Heading, type HeadingSize, Inline, ListDetailLayout, ListRow, PageHeader, PageLayout, Pagination, Prose, SearchField, SegmentedControl, SidebarLayout, Spacer, Spinner, Stack, type Status, StatusDot, Table, Tabs, Text, TextLink, type TextSize, type Tokens, headingStyle, textStyle, tokens, useBreakpoint, useListDetail, useSidebar };
package/dist/ui/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ensureStyle, injectBaseReset } from '../chunk-3HQGHPNC.js';
2
- import { createContext, useRef, useId, useState, useEffect, useMemo, useContext, useLayoutEffect, Fragment as Fragment$1 } from 'react';
2
+ import { createContext, useRef, useId, useState, useMemo, useContext, useEffect, useLayoutEffect, Fragment as Fragment$1 } from 'react';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
4
 
5
5
  // src/ui/tokens.ts
@@ -20,6 +20,7 @@ var tokens = {
20
20
  ring: "var(--color-ring-primary, #2563eb)",
21
21
  // ── Status / brand semantics ──
22
22
  danger: "var(--nb-color-danger, #dc2626)",
23
+ dangerFg: "var(--nb-color-danger-foreground, #ffffff)",
23
24
  success: "var(--nb-color-success, #059669)",
24
25
  warning: "var(--nb-color-warning, #f59e0b)",
25
26
  processing: "var(--nb-color-processing, #7c3aed)",
@@ -292,6 +293,10 @@ function Button({
292
293
  vars["--nb-btn-bg"] = tokens.accent;
293
294
  vars["--nb-btn-bg-hover"] = `color-mix(in oklab, ${tokens.accent} 88%, black)`;
294
295
  vars["--nb-btn-fg"] = tokens.accentFg;
296
+ } else if (variant === "danger") {
297
+ vars["--nb-btn-bg"] = tokens.danger;
298
+ vars["--nb-btn-bg-hover"] = `color-mix(in oklab, ${tokens.danger} 88%, black)`;
299
+ vars["--nb-btn-fg"] = tokens.dangerFg;
295
300
  } else if (variant === "secondary") {
296
301
  vars["--nb-btn-bg"] = tokens.bgSubtle;
297
302
  vars["--nb-btn-bg-hover"] = tokens.bgRaised;
@@ -410,8 +415,283 @@ function Card({
410
415
  }
411
416
  );
412
417
  }
413
- var STYLE_ID3 = "nb-synapse-drawer";
418
+ function inClosedDetails(el) {
419
+ const details = el.closest("details:not([open])");
420
+ if (!details) return false;
421
+ const summary = details.querySelector("summary");
422
+ return !summary || !summary.contains(el);
423
+ }
424
+ var CANDIDATES = "a[href], button, input, select, textarea, summary, [tabindex]";
425
+ function tabbables(panel) {
426
+ return Array.from(panel.querySelectorAll(CANDIDATES)).filter(
427
+ (el) => el.tabIndex >= 0 && !el.matches(":disabled") && !el.closest("[hidden]") && !inClosedDetails(el) && el.type !== "hidden"
428
+ );
429
+ }
430
+ var openOverlays = [];
431
+ var lockedOverflow = "";
432
+ var handledEscapes = /* @__PURE__ */ new WeakSet();
433
+ function isInnermost(self) {
434
+ const selfAt = openOverlays.indexOf(self);
435
+ return openOverlays.every((other, at) => {
436
+ if (other === self || !other.panel || !self.panel) return true;
437
+ if (self.panel.contains(other.panel)) return false;
438
+ if (other.panel.contains(self.panel)) return true;
439
+ return at < selfAt;
440
+ });
441
+ }
442
+ function useModal(open, panelRef, { onEscape, initialFocus }) {
443
+ const escapeRef = useRef(onEscape);
444
+ escapeRef.current = onEscape;
445
+ const initialFocusRef = useRef(initialFocus);
446
+ initialFocusRef.current = initialFocus;
447
+ useEffect(() => {
448
+ if (!open) return;
449
+ const panel = panelRef.current;
450
+ const previouslyFocused = document.activeElement;
451
+ const entry = { panel, restoreTo: previouslyFocused };
452
+ const nested = openOverlays.filter((o) => panel && o.panel && panel.contains(o.panel)).at(-1);
453
+ if (nested) {
454
+ entry.restoreTo = nested.restoreTo;
455
+ nested.restoreTo = panel;
456
+ }
457
+ const { body } = document;
458
+ if (openOverlays.length === 0) lockedOverflow = body.style.overflow;
459
+ openOverlays.push(entry);
460
+ const innermost = () => isInnermost(entry);
461
+ if (!(panel && previouslyFocused && panel.contains(previouslyFocused))) {
462
+ (initialFocusRef.current?.() ?? panel)?.focus();
463
+ }
464
+ body.style.overflow = "hidden";
465
+ const onWindowKeyDown = (e) => {
466
+ if (e.key !== "Escape" || handledEscapes.has(e) || !innermost()) return;
467
+ handledEscapes.add(e);
468
+ e.preventDefault();
469
+ escapeRef.current();
470
+ };
471
+ window.addEventListener("keydown", onWindowKeyDown);
472
+ const onPanelKeyDown = (e) => {
473
+ if (e.key !== "Tab" || !panel || !innermost()) return;
474
+ const focusables = tabbables(panel);
475
+ if (focusables.length === 0) {
476
+ e.preventDefault();
477
+ return;
478
+ }
479
+ const first = focusables[0];
480
+ const last = focusables[focusables.length - 1];
481
+ const active = document.activeElement;
482
+ if (e.shiftKey && (active === first || active === panel)) {
483
+ e.preventDefault();
484
+ last.focus();
485
+ } else if (!e.shiftKey && active === last) {
486
+ e.preventDefault();
487
+ first.focus();
488
+ }
489
+ };
490
+ panel?.addEventListener("keydown", onPanelKeyDown);
491
+ return () => {
492
+ window.removeEventListener("keydown", onWindowKeyDown);
493
+ panel?.removeEventListener("keydown", onPanelKeyDown);
494
+ const at = openOverlays.indexOf(entry);
495
+ if (at !== -1) openOverlays.splice(at, 1);
496
+ if (openOverlays.length === 0) body.style.overflow = lockedOverflow;
497
+ entry.restoreTo?.focus?.();
498
+ };
499
+ }, [open, panelRef]);
500
+ }
501
+ var STYLE_ID3 = "nb-synapse-confirm-dialog";
414
502
  var RULES3 = `
503
+ /* border-box on both, because the kit cannot assume the host resets it: under
504
+ content-box a full-width panel is its padding wider than a phone screen. */
505
+ .nb-confirm-scrim {
506
+ box-sizing: border-box;
507
+ position: fixed; inset: 0; z-index: 1010;
508
+ display: flex; align-items: center; justify-content: center;
509
+ padding: 1rem;
510
+ background: rgba(0, 0, 0, 0.4);
511
+ animation: nb-confirm-fade 160ms ease;
512
+ }
513
+ .nb-confirm {
514
+ box-sizing: border-box;
515
+ width: 100%; max-width: 28rem; max-height: 100%;
516
+ overflow-y: auto;
517
+ outline: none;
518
+ animation: nb-confirm-in 160ms cubic-bezier(0.2, 0, 0, 1);
519
+ }
520
+ .nb-confirm__actions {
521
+ display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem;
522
+ }
523
+ @keyframes nb-confirm-fade { from { opacity: 0; } }
524
+ @keyframes nb-confirm-in { from { opacity: 0; transform: translateY(4px); } }
525
+ @media (pointer: coarse) {
526
+ .nb-confirm__actions .nb-btn { min-height: 44px; }
527
+ }
528
+ `;
529
+ function ConfirmDialog({
530
+ open,
531
+ onOpenChange,
532
+ title,
533
+ description,
534
+ children,
535
+ confirmLabel = "Confirm",
536
+ cancelLabel = "Cancel",
537
+ pendingLabel,
538
+ destructive = false,
539
+ onConfirm
540
+ }) {
541
+ ensureStyle(STYLE_ID3, RULES3);
542
+ const panelRef = useRef(null);
543
+ const titleId = useId();
544
+ const descriptionId = useId();
545
+ const [pending, setPending] = useState(false);
546
+ const [error, setError] = useState(null);
547
+ useEffect(() => {
548
+ if (open) {
549
+ setError(null);
550
+ setPending(false);
551
+ }
552
+ }, [open]);
553
+ const dismiss = () => {
554
+ if (!pending) onOpenChange(false);
555
+ };
556
+ const focusTarget = () => panelRef.current?.querySelector(
557
+ destructive ? "[data-nb-confirm-cancel]" : "[data-nb-confirm-confirm]"
558
+ );
559
+ useModal(open, panelRef, { onEscape: dismiss, initialFocus: focusTarget });
560
+ useEffect(() => {
561
+ if (error) focusTarget()?.focus();
562
+ }, [error]);
563
+ const confirm = async () => {
564
+ if (pending) return;
565
+ setPending(true);
566
+ setError(null);
567
+ try {
568
+ await onConfirm();
569
+ setPending(false);
570
+ onOpenChange(false);
571
+ } catch (err) {
572
+ const message = err instanceof Error ? err.message : String(err);
573
+ setError(message || "That didn't go through. Try again.");
574
+ setPending(false);
575
+ }
576
+ };
577
+ if (!open) return null;
578
+ const panelStyle = {
579
+ padding: "1.25rem",
580
+ borderRadius: tokens.radiusMd,
581
+ border: `${tokens.borderWidth} solid ${tokens.border}`,
582
+ background: tokens.bg,
583
+ color: tokens.fg,
584
+ boxShadow: tokens.shadowLg,
585
+ fontFamily: tokens.fontSans
586
+ };
587
+ return (
588
+ // biome-ignore lint/a11y/useKeyWithClickEvents: scrim click is a bonus mouse affordance; Escape and Cancel are the keyboard dismissal paths.
589
+ // biome-ignore lint/a11y/noStaticElementInteractions: the scrim is a decorative dismissal backdrop; the panel below owns the alertdialog role/semantics.
590
+ /* @__PURE__ */ jsx(
591
+ "div",
592
+ {
593
+ className: "nb-confirm-scrim",
594
+ onClick: (e) => {
595
+ if (e.target === e.currentTarget) dismiss();
596
+ },
597
+ children: /* @__PURE__ */ jsxs(
598
+ "div",
599
+ {
600
+ ref: panelRef,
601
+ role: "alertdialog",
602
+ "aria-modal": "true",
603
+ "aria-labelledby": titleId,
604
+ "aria-describedby": description != null ? descriptionId : void 0,
605
+ "aria-busy": pending || void 0,
606
+ tabIndex: -1,
607
+ className: "nb-confirm",
608
+ style: panelStyle,
609
+ children: [
610
+ /* @__PURE__ */ jsx(
611
+ "h2",
612
+ {
613
+ id: titleId,
614
+ style: {
615
+ margin: 0,
616
+ fontFamily: tokens.fontSans,
617
+ fontSize: tokens.textBaseSize,
618
+ lineHeight: tokens.textBaseLine,
619
+ fontWeight: tokens.weightSemibold,
620
+ color: tokens.fg
621
+ },
622
+ children: title
623
+ }
624
+ ),
625
+ description != null ? /* @__PURE__ */ jsx(
626
+ "p",
627
+ {
628
+ id: descriptionId,
629
+ style: {
630
+ margin: "0.25rem 0 0",
631
+ fontSize: tokens.textSmSize,
632
+ lineHeight: tokens.textSmLine,
633
+ color: tokens.fgMuted
634
+ },
635
+ children: description
636
+ }
637
+ ) : null,
638
+ children != null ? /* @__PURE__ */ jsx(
639
+ "div",
640
+ {
641
+ style: {
642
+ marginTop: "1rem",
643
+ fontSize: tokens.textSmSize,
644
+ lineHeight: tokens.textSmLine
645
+ },
646
+ children
647
+ }
648
+ ) : null,
649
+ error ? /* @__PURE__ */ jsx(
650
+ "p",
651
+ {
652
+ role: "alert",
653
+ style: {
654
+ margin: "0.75rem 0 0",
655
+ fontSize: tokens.textSmSize,
656
+ lineHeight: tokens.textSmLine,
657
+ color: tokens.danger
658
+ },
659
+ children: error
660
+ }
661
+ ) : null,
662
+ /* @__PURE__ */ jsxs("div", { className: "nb-confirm__actions", style: { marginTop: "1.25rem" }, children: [
663
+ /* @__PURE__ */ jsx(
664
+ Button,
665
+ {
666
+ variant: "secondary",
667
+ size: "sm",
668
+ "data-nb-confirm-cancel": "",
669
+ disabled: pending,
670
+ onClick: dismiss,
671
+ children: cancelLabel
672
+ }
673
+ ),
674
+ /* @__PURE__ */ jsx(
675
+ Button,
676
+ {
677
+ variant: destructive ? "danger" : "primary",
678
+ size: "sm",
679
+ "data-nb-confirm-confirm": "",
680
+ disabled: pending,
681
+ onClick: () => void confirm(),
682
+ children: pending ? pendingLabel ?? confirmLabel : confirmLabel
683
+ }
684
+ )
685
+ ] })
686
+ ]
687
+ }
688
+ )
689
+ }
690
+ )
691
+ );
692
+ }
693
+ var STYLE_ID4 = "nb-synapse-drawer";
694
+ var RULES4 = `
415
695
  .nb-drawer-scrim {
416
696
  position: fixed; inset: 0; z-index: 1000;
417
697
  display: flex; background: rgba(0, 0, 0, 0.32);
@@ -438,18 +718,6 @@ var RULES3 = `
438
718
  .nb-drawer-iconbtn { min-width: 44px; min-height: 44px; }
439
719
  }
440
720
  `;
441
- function inClosedDetails(el) {
442
- const details = el.closest("details:not([open])");
443
- if (!details) return false;
444
- const summary = details.querySelector("summary");
445
- return !summary || !summary.contains(el);
446
- }
447
- var CANDIDATES = "a[href], button, input, select, textarea, summary, [tabindex]";
448
- function tabbables(panel) {
449
- return Array.from(panel.querySelectorAll(CANDIDATES)).filter(
450
- (el) => el.tabIndex >= 0 && !el.matches(":disabled") && !el.closest("[hidden]") && !inClosedDetails(el) && el.type !== "hidden"
451
- );
452
- }
453
721
  var DrawerContext = createContext(null);
454
722
  function DrawerRoot({
455
723
  open,
@@ -461,54 +729,11 @@ function DrawerRoot({
461
729
  children,
462
730
  ...rest
463
731
  }) {
464
- ensureStyle(STYLE_ID3, RULES3);
732
+ ensureStyle(STYLE_ID4, RULES4);
465
733
  const panelRef = useRef(null);
466
734
  const labelId = useId();
467
735
  const [hasTitle, setHasTitle] = useState(false);
468
- useEffect(() => {
469
- if (!open) return;
470
- const onKey = (e) => {
471
- if (e.key === "Escape") {
472
- e.preventDefault();
473
- (onEscape ?? onClose)();
474
- }
475
- };
476
- window.addEventListener("keydown", onKey);
477
- return () => window.removeEventListener("keydown", onKey);
478
- }, [open, onEscape, onClose]);
479
- useEffect(() => {
480
- if (!open) return;
481
- const panel = panelRef.current;
482
- const previouslyFocused = document.activeElement;
483
- panel?.focus();
484
- const { body } = document;
485
- const prevOverflow = body.style.overflow;
486
- body.style.overflow = "hidden";
487
- const onKeyDown = (e) => {
488
- if (e.key !== "Tab" || !panel) return;
489
- const focusables = tabbables(panel);
490
- if (focusables.length === 0) {
491
- e.preventDefault();
492
- return;
493
- }
494
- const first = focusables[0];
495
- const last = focusables[focusables.length - 1];
496
- const active = document.activeElement;
497
- if (e.shiftKey && (active === first || active === panel)) {
498
- e.preventDefault();
499
- last.focus();
500
- } else if (!e.shiftKey && active === last) {
501
- e.preventDefault();
502
- first.focus();
503
- }
504
- };
505
- panel?.addEventListener("keydown", onKeyDown);
506
- return () => {
507
- panel?.removeEventListener("keydown", onKeyDown);
508
- body.style.overflow = prevOverflow;
509
- previouslyFocused?.focus?.();
510
- };
511
- }, [open]);
736
+ useModal(open, panelRef, { onEscape: onEscape ?? onClose });
512
737
  const ctxValue = useMemo(() => ({ labelId, setHasTitle }), [labelId]);
513
738
  if (!open) return null;
514
739
  const isBottom = side === "bottom";
@@ -740,8 +965,8 @@ function EmptyState({ icon, title, description, action, style, ...rest }) {
740
965
  }
741
966
  );
742
967
  }
743
- var STYLE_ID4 = "nb-synapse-listrow";
744
- var RULES4 = `
968
+ var STYLE_ID5 = "nb-synapse-listrow";
969
+ var RULES5 = `
745
970
  .nb-listrow { transition: background 140ms ease; }
746
971
  .nb-listrow--interactive { cursor: pointer; }
747
972
  .nb-listrow--interactive:hover { background: var(--nb-listrow-hover); }
@@ -756,7 +981,7 @@ function ListRow({
756
981
  className,
757
982
  ...rest
758
983
  }) {
759
- ensureStyle(STYLE_ID4, RULES4);
984
+ ensureStyle(STYLE_ID5, RULES5);
760
985
  const rowStyle = {
761
986
  "--nb-listrow-hover": tokens.bgSubtle,
762
987
  display: "grid",
@@ -839,8 +1064,8 @@ function ListRow({
839
1064
  }
840
1065
  );
841
1066
  }
842
- var STYLE_ID5 = "nb-synapse-page-header";
843
- var RULES5 = `
1067
+ var STYLE_ID6 = "nb-synapse-page-header";
1068
+ var RULES6 = `
844
1069
  .nb-page-header__desc {
845
1070
  display: -webkit-box;
846
1071
  -webkit-box-orient: vertical;
@@ -859,7 +1084,7 @@ function PageHeader({
859
1084
  style,
860
1085
  ...rest
861
1086
  }) {
862
- ensureStyle(STYLE_ID5, RULES5);
1087
+ ensureStyle(STYLE_ID6, RULES6);
863
1088
  const descVars = descriptionLines > 0 ? { "--nb-ph-desc-lines": String(descriptionLines) } : {};
864
1089
  return /* @__PURE__ */ jsxs(
865
1090
  "header",
@@ -945,8 +1170,8 @@ function Pagination({ page, pageCount, onChange, style, ...rest }) {
945
1170
  }
946
1171
  );
947
1172
  }
948
- var STYLE_ID6 = "nb-synapse-prose";
949
- var RULES6 = `
1173
+ var STYLE_ID7 = "nb-synapse-prose";
1174
+ var RULES7 = `
950
1175
  .nb-prose {
951
1176
  font-family: ${tokens.fontSans};
952
1177
  font-size: ${tokens.textBaseSize};
@@ -971,15 +1196,15 @@ var RULES6 = `
971
1196
  .nb-prose strong { font-weight: ${tokens.weightSemibold}; }
972
1197
  `;
973
1198
  function Prose({ html, children, className, ...rest }) {
974
- ensureStyle(STYLE_ID6, RULES6);
1199
+ ensureStyle(STYLE_ID7, RULES7);
975
1200
  const cls = `nb-prose ${className ?? ""}`.trim();
976
1201
  if (html !== void 0) {
977
1202
  return /* @__PURE__ */ jsx("div", { className: cls, dangerouslySetInnerHTML: { __html: html }, ...rest });
978
1203
  }
979
1204
  return /* @__PURE__ */ jsx("div", { className: cls, ...rest, children });
980
1205
  }
981
- var STYLE_ID7 = "nb-synapse-searchfield";
982
- var RULES7 = `
1206
+ var STYLE_ID8 = "nb-synapse-searchfield";
1207
+ var RULES8 = `
983
1208
  .nb-search { font-family: var(--nb-search-font); color: var(--nb-search-fg); outline: none; }
984
1209
  .nb-search::placeholder { color: var(--nb-search-placeholder); }
985
1210
  .nb-search--underline {
@@ -1009,7 +1234,7 @@ function SearchField({
1009
1234
  type = "search",
1010
1235
  ...rest
1011
1236
  }) {
1012
- ensureStyle(STYLE_ID7, RULES7);
1237
+ ensureStyle(STYLE_ID8, RULES8);
1013
1238
  const fieldStyle = {
1014
1239
  "--nb-search-font": tokens.fontSans,
1015
1240
  "--nb-search-fg": tokens.fg,
@@ -1032,8 +1257,8 @@ function SearchField({
1032
1257
  }
1033
1258
  );
1034
1259
  }
1035
- var STYLE_ID8 = "nb-synapse-segmented";
1036
- var RULES8 = `
1260
+ var STYLE_ID9 = "nb-synapse-segmented";
1261
+ var RULES9 = `
1037
1262
  .nb-seg {
1038
1263
  display: inline-flex;
1039
1264
  gap: 2px;
@@ -1068,7 +1293,7 @@ function SegmentedControl({
1068
1293
  className,
1069
1294
  ...rest
1070
1295
  }) {
1071
- ensureStyle(STYLE_ID8, RULES8);
1296
+ ensureStyle(STYLE_ID9, RULES9);
1072
1297
  const trackStyle = {
1073
1298
  "--nb-seg-radius": tokens.radiusSm,
1074
1299
  "--nb-seg-track": tokens.bgSubtle,
@@ -1097,14 +1322,14 @@ function SegmentedControl({
1097
1322
  )) })
1098
1323
  );
1099
1324
  }
1100
- var STYLE_ID9 = "nb-synapse-spinner";
1101
- var RULES9 = `
1325
+ var STYLE_ID10 = "nb-synapse-spinner";
1326
+ var RULES10 = `
1102
1327
  @keyframes nb-spin { to { transform: rotate(360deg); } }
1103
1328
  .nb-spinner { animation: nb-spin 0.7s linear infinite; }
1104
1329
  @media (prefers-reduced-motion: reduce) { .nb-spinner { animation-duration: 1.6s; } }
1105
1330
  `;
1106
1331
  function Spinner({ size = 16, color, style, className, ...rest }) {
1107
- ensureStyle(STYLE_ID9, RULES9);
1332
+ ensureStyle(STYLE_ID10, RULES10);
1108
1333
  const spinnerStyle = {
1109
1334
  display: "inline-block",
1110
1335
  flexShrink: 0,
@@ -1133,7 +1358,7 @@ var COLOR = {
1133
1358
  failed: tokens.danger,
1134
1359
  idle: tokens.fgFaint
1135
1360
  };
1136
- var STYLE_ID10 = "nb-synapse-statusdot";
1361
+ var STYLE_ID11 = "nb-synapse-statusdot";
1137
1362
  var KEYFRAMES = `
1138
1363
  @keyframes nb-statusdot-pulse {
1139
1364
  0% { box-shadow: 0 0 0 0 var(--nb-statusdot-ring); transform: scale(1); }
@@ -1155,7 +1380,7 @@ function StatusDot({
1155
1380
  className,
1156
1381
  ...rest
1157
1382
  }) {
1158
- ensureStyle(STYLE_ID10, KEYFRAMES);
1383
+ ensureStyle(STYLE_ID11, KEYFRAMES);
1159
1384
  const dotColor = color ?? COLOR[status];
1160
1385
  const working = status === "working";
1161
1386
  const dotStyle = {
@@ -1178,8 +1403,8 @@ function StatusDot({
1178
1403
  }
1179
1404
  );
1180
1405
  }
1181
- var STYLE_ID11 = "nb-synapse-table";
1182
- var RULES10 = `
1406
+ var STYLE_ID12 = "nb-synapse-table";
1407
+ var RULES11 = `
1183
1408
  .nb-table { width: 100%; border-collapse: collapse; font-family: var(--nb-table-font); }
1184
1409
  .nb-table th, .nb-table td {
1185
1410
  padding: 0.55rem 0.75rem;
@@ -1209,7 +1434,7 @@ function Table({
1209
1434
  className,
1210
1435
  ...rest
1211
1436
  }) {
1212
- ensureStyle(STYLE_ID11, RULES10);
1437
+ ensureStyle(STYLE_ID12, RULES11);
1213
1438
  const vars = {
1214
1439
  "--nb-table-font": tokens.fontSans,
1215
1440
  "--nb-table-fg": tokens.fg,
@@ -1275,8 +1500,8 @@ function Table({
1275
1500
  }
1276
1501
  );
1277
1502
  }
1278
- var STYLE_ID12 = "nb-synapse-tabs";
1279
- var RULES11 = `
1503
+ var STYLE_ID13 = "nb-synapse-tabs";
1504
+ var RULES12 = `
1280
1505
  .nb-tabs {
1281
1506
  display: flex;
1282
1507
  align-items: stretch;
@@ -1334,7 +1559,7 @@ function Tabs({
1334
1559
  onKeyDown: callerKeyDown,
1335
1560
  ...rest
1336
1561
  }) {
1337
- ensureStyle(STYLE_ID12, RULES11);
1562
+ ensureStyle(STYLE_ID13, RULES12);
1338
1563
  const barRef = useRef(null);
1339
1564
  const barStyle = {
1340
1565
  "--nb-tabs-gap": "1.25rem",
@@ -1611,8 +1836,8 @@ function useSidebar() {
1611
1836
  if (!ctx) throw new Error("useSidebar must be used within <SidebarLayout>");
1612
1837
  return ctx;
1613
1838
  }
1614
- var STYLE_ID13 = "nb-synapse-sidebarlayout";
1615
- var RULES12 = `
1839
+ var STYLE_ID14 = "nb-synapse-sidebarlayout";
1840
+ var RULES13 = `
1616
1841
  .nb-sidebar-drawer { transition: transform 220ms cubic-bezier(0.2, 0, 0, 1); }
1617
1842
  .nb-sidebar-scrim { animation: nb-sidebar-fade 180ms ease; }
1618
1843
  @keyframes nb-sidebar-fade { from { opacity: 0; } to { opacity: 1; } }
@@ -1662,7 +1887,7 @@ function SidebarLayoutRoot({
1662
1887
  }
1663
1888
  function Sidebar({ style, children, ...rest }) {
1664
1889
  const { collapsed, mode, side, width, open, setOpen } = useSidebar();
1665
- ensureStyle(STYLE_ID13, RULES12);
1890
+ ensureStyle(STYLE_ID14, RULES13);
1666
1891
  if (!collapsed) {
1667
1892
  const border = `${tokens.borderWidth} solid ${tokens.border}`;
1668
1893
  return /* @__PURE__ */ jsx(
@@ -1787,6 +2012,6 @@ function Trigger({ children, style, onClick, ...rest }) {
1787
2012
  }
1788
2013
  var SidebarLayout = Object.assign(SidebarLayoutRoot, { Sidebar, Main, Trigger });
1789
2014
 
1790
- export { AppFrame, Avatar, Badge, Breadcrumb, Button, Card, Divider, Drawer, EmptyState, Heading, Inline, ListDetailLayout, ListRow, PageHeader, PageLayout, Pagination, Prose, SearchField, SegmentedControl, SidebarLayout, Spacer, Spinner, Stack, StatusDot, Table, Tabs, Text, TextLink, headingStyle, textStyle, tokens, useBreakpoint, useListDetail, useSidebar };
2015
+ export { AppFrame, Avatar, Badge, Breadcrumb, Button, Card, ConfirmDialog, Divider, Drawer, EmptyState, Heading, Inline, ListDetailLayout, ListRow, PageHeader, PageLayout, Pagination, Prose, SearchField, SegmentedControl, SidebarLayout, Spacer, Spinner, Stack, StatusDot, Table, Tabs, Text, TextLink, headingStyle, textStyle, tokens, useBreakpoint, useListDetail, useSidebar };
1791
2016
  //# sourceMappingURL=index.js.map
1792
2017
  //# sourceMappingURL=index.js.map