@broberg/app-shell 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -53,6 +53,38 @@ the user menu reads and writes theme, palette, surfaces and backdrop through it.
53
53
  | `TenantSwitcher` | The user's own organisations, the active one marked, switching through the app's route (0.7.1). |
54
54
  | `AnalyticsPage` | The app's own visitors, page views, page views per day and most visited pages, from Upmetrics (0.6.0). |
55
55
 
56
+ ## 0.9.0 — ConfirmDialog: the confirm window instead of `window.confirm()` (F092.13)
57
+
58
+ Asked for by appkit for «Slet» in a `@broberg/data-table` row. No native `confirm()` or `<dialog>` (D-4cd764).
59
+
60
+ ```tsx
61
+ import { ConfirmDialog } from "@broberg/app-shell/preact";
62
+
63
+ <ConfirmDialog
64
+ open={!!pending}
65
+ title="Slet dokument?"
66
+ body={`«${pending?.title}» slettes permanent.`}
67
+ confirmLabel="Slet"
68
+ destructive
69
+ busy={deleting}
70
+ error={deleteError}
71
+ onConfirm={() => remove(pending!.id)}
72
+ onCancel={() => setPending(null)}
73
+ testId="doc-delete"
74
+ />
75
+ ```
76
+
77
+ - `role="alertdialog"`, `aria-modal`, title and body wired with aria-labelledby/describedby. Closed renders nothing.
78
+ - **Focus starts on Cancel**, the safe button, so a stray Enter deletes nothing. Tab stays inside the window, and focus returns to whatever had it before (the ⋮ button).
79
+ - **Escape or a click on the backdrop cancels**; a click inside the window does not.
80
+ - **While `busy`**, both buttons are disabled and neither Escape nor the backdrop closes it: the request has already gone, and a window that vanished would let the user think they had cancelled a deletion that is happening. The confirm button shows a spinner.
81
+ - `error` appears inside the window as `role="alert"`; the window stays open so the user can retry or cancel. You decide when to close it (set `open` false on success).
82
+ - `destructive` makes the confirm button red. Labels default to Danish («Bekræft» / «Annuller»).
83
+ - testids: `<testId>`, `-backdrop`, `-title`, `-body`, `-error`, `-cancel`, `-confirm` (default prefix `confirm-dialog`).
84
+ - Styled in `css/app-shell.css` (`bas-confirm*`) on theme tokens only.
85
+
86
+ Purely additive: nothing that rendered in 0.8.0 renders differently.
87
+
56
88
  ## 0.8.0 — TenantSwitcher on a phone (F029.10, found by appkit's pilot)
57
89
 
58
90
  - **The menu stays on screen.** It is anchored to the switcher, which sits among
package/css/app-shell.css CHANGED
@@ -391,3 +391,38 @@
391
391
  .bas-tenant__role { flex: none; font-size: 11.5px; color: var(--fg-muted); }
392
392
  .bas-tenant__note { margin: 0; padding: 10px 14px; font-size: 12.5px; color: var(--fg-muted); }
393
393
  .bas-tenant__note.is-error { color: var(--danger); }
394
+
395
+ /* ── F092.13 confirm dialog ── */
396
+ .bas-confirm-backdrop {
397
+ position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 16px;
398
+ background: rgba(0, 0, 0, .45);
399
+ }
400
+ .bas-confirm {
401
+ width: min(440px, 100%); padding: 20px; display: flex; flex-direction: column; gap: 12px;
402
+ background: var(--bg-card, var(--bg)); color: var(--fg);
403
+ border: 1px solid var(--border); border-radius: var(--radius, 8px);
404
+ box-shadow: 0 16px 48px rgba(0, 0, 0, .24);
405
+ }
406
+ .bas-confirm__title { margin: 0; font-size: 17px; font-weight: 600; }
407
+ .bas-confirm__body { color: var(--fg-muted); font-size: 14px; line-height: 1.5; }
408
+ .bas-confirm__error { color: var(--danger); background: var(--danger-bg); border: 1px solid var(--danger-border); border-radius: var(--radius, 8px); padding: 8px 10px; font-size: 13px; }
409
+ .bas-confirm__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
410
+ .bas-confirm__btn {
411
+ display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; cursor: pointer;
412
+ font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius, 8px);
413
+ transition: background-color 120ms ease, border-color 120ms ease;
414
+ }
415
+ .bas-confirm__btn:hover:not(:disabled) { background: var(--hover); }
416
+ .bas-confirm__btn:active:not(:disabled) { background: var(--active); }
417
+ .bas-confirm__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
418
+ .bas-confirm__btn:disabled { opacity: .6; cursor: not-allowed; }
419
+ .bas-confirm__btn.is-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
420
+ .bas-confirm__btn.is-primary:hover:not(:disabled) { background: var(--accent); filter: brightness(1.08); }
421
+ .bas-confirm__btn.is-danger { background: var(--danger-btn, var(--danger)); color: var(--accent-fg); border-color: var(--danger-btn, var(--danger)); }
422
+ .bas-confirm__btn.is-danger:hover:not(:disabled) { background: var(--danger-btn, var(--danger)); filter: brightness(1.08); }
423
+ .bas-confirm__spinner {
424
+ width: 14px; height: 14px; border-radius: 999px; border: 2px solid currentColor; border-right-color: transparent;
425
+ animation: bas-confirm-spin 700ms linear infinite;
426
+ }
427
+ @keyframes bas-confirm-spin { to { transform: rotate(360deg); } }
428
+ @media (prefers-reduced-motion: reduce) { .bas-confirm__spinner { animation: none; } }
package/dist/preact.cjs CHANGED
@@ -1454,6 +1454,97 @@ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch, roleLabel }) {
1454
1454
  ] }) : null
1455
1455
  ] });
1456
1456
  }
1457
+ var confirmDialogSeq = 0;
1458
+ function ConfirmDialog(p) {
1459
+ const t = p.testId ?? "confirm-dialog";
1460
+ const panel = hooks.useRef(null);
1461
+ const cancelBtn = hooks.useRef(null);
1462
+ const ids = hooks.useMemo(() => {
1463
+ const n = ++confirmDialogSeq;
1464
+ return { title: `bas-confirm-title-${n}`, body: `bas-confirm-body-${n}` };
1465
+ }, []);
1466
+ const live = hooks.useRef(p);
1467
+ live.current = p;
1468
+ hooks.useEffect(() => {
1469
+ if (!p.open) return;
1470
+ const before = document.activeElement;
1471
+ cancelBtn.current?.focus();
1472
+ const onKey = (e) => {
1473
+ if (e.key === "Escape") {
1474
+ e.preventDefault();
1475
+ if (!live.current.busy) live.current.onCancel();
1476
+ return;
1477
+ }
1478
+ if (e.key !== "Tab" || !panel.current) return;
1479
+ const focusable = [...panel.current.querySelectorAll("button:not([disabled])")];
1480
+ if (focusable.length === 0) {
1481
+ e.preventDefault();
1482
+ return;
1483
+ }
1484
+ const first = focusable[0];
1485
+ const last = focusable[focusable.length - 1];
1486
+ const active = document.activeElement;
1487
+ if (e.shiftKey && (active === first || !panel.current.contains(active))) {
1488
+ e.preventDefault();
1489
+ last.focus();
1490
+ } else if (!e.shiftKey && (active === last || !panel.current.contains(active))) {
1491
+ e.preventDefault();
1492
+ first.focus();
1493
+ }
1494
+ };
1495
+ document.addEventListener("keydown", onKey);
1496
+ return () => {
1497
+ document.removeEventListener("keydown", onKey);
1498
+ if (before && typeof before.focus === "function" && document.contains(before)) before.focus();
1499
+ };
1500
+ }, [p.open]);
1501
+ if (!p.open) return null;
1502
+ return /* @__PURE__ */ jsxRuntime.jsx(
1503
+ "div",
1504
+ {
1505
+ class: "bas-confirm-backdrop",
1506
+ "data-testid": `${t}-backdrop`,
1507
+ onMouseDown: (e) => {
1508
+ if (e.target === e.currentTarget && !p.busy) p.onCancel();
1509
+ },
1510
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
1511
+ "div",
1512
+ {
1513
+ ref: panel,
1514
+ class: "bas-confirm",
1515
+ role: "alertdialog",
1516
+ "aria-modal": "true",
1517
+ "aria-labelledby": ids.title,
1518
+ "aria-describedby": p.body ? ids.body : void 0,
1519
+ "aria-busy": p.busy ? "true" : void 0,
1520
+ "data-testid": t,
1521
+ children: [
1522
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { class: "bas-confirm__title", id: ids.title, "data-testid": `${t}-title`, children: p.title }),
1523
+ p.body ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-confirm__body", id: ids.body, "data-testid": `${t}-body`, children: p.body }) : null,
1524
+ p.error ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-confirm__error", role: "alert", "data-testid": `${t}-error`, children: p.error }) : null,
1525
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-confirm__actions", children: [
1526
+ /* @__PURE__ */ jsxRuntime.jsx("button", { ref: cancelBtn, type: "button", class: "bas-confirm__btn", disabled: p.busy, "data-testid": `${t}-cancel`, onClick: () => p.onCancel(), children: p.cancelLabel ?? "Annuller" }),
1527
+ /* @__PURE__ */ jsxRuntime.jsxs(
1528
+ "button",
1529
+ {
1530
+ type: "button",
1531
+ class: `bas-confirm__btn is-primary${p.destructive ? " is-danger" : ""}${p.busy ? " is-busy" : ""}`,
1532
+ disabled: p.busy,
1533
+ "data-testid": `${t}-confirm`,
1534
+ onClick: () => p.onConfirm(),
1535
+ children: [
1536
+ p.busy ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-confirm__spinner", "aria-hidden": "true" }) : null,
1537
+ p.confirmLabel ?? "Bekr\xE6ft"
1538
+ ]
1539
+ }
1540
+ )
1541
+ ] })
1542
+ ]
1543
+ }
1544
+ )
1545
+ }
1546
+ );
1547
+ }
1457
1548
 
1458
1549
  exports.AVATAR_MAX_BYTES = AVATAR_MAX_BYTES;
1459
1550
  exports.AVATAR_TYPES = AVATAR_TYPES;
@@ -1463,6 +1554,7 @@ exports.AnalyticsError = AnalyticsError;
1463
1554
  exports.AnalyticsPage = AnalyticsPage;
1464
1555
  exports.AppShell = AppShell;
1465
1556
  exports.Avatar = Avatar;
1557
+ exports.ConfirmDialog = ConfirmDialog;
1466
1558
  exports.NotificationBell = NotificationBell;
1467
1559
  exports.PageHeader = PageHeader;
1468
1560
  exports.PageTabs = PageTabs;