@broberg/app-shell 0.7.1 → 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 +44 -0
- package/css/app-shell.css +38 -1
- package/dist/preact.cjs +108 -3
- package/dist/preact.cjs.map +1 -1
- package/dist/preact.d.cts +26 -2
- package/dist/preact.d.ts +26 -2
- package/dist/preact.js +109 -5
- package/dist/preact.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,50 @@ 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
|
+
|
|
88
|
+
## 0.8.0 — TenantSwitcher on a phone (F029.10, found by appkit's pilot)
|
|
89
|
+
|
|
90
|
+
- **The menu stays on screen.** It is anchored to the switcher, which sits among
|
|
91
|
+
your actions rather than at the edge, so at 393 px it ran off the right side.
|
|
92
|
+
It is now measured when it opens and shifted back inside, 16 px from the edge.
|
|
93
|
+
- **Changed output: no raw role.** Roles are your app's words, so the switcher
|
|
94
|
+
cannot translate them, and a bare «member» in a Danish UI is worse than nothing.
|
|
95
|
+
Pass `roleLabel={(role) => …}` to show them; without it, other rows show no role.
|
|
96
|
+
The active row still says «aktiv», and a suspended one still says so.
|
|
97
|
+
- **On a phone the switcher takes at most 40% of the width**, and the organisation
|
|
98
|
+
name is cut with «…» before the page title is.
|
|
99
|
+
|
|
56
100
|
## 0.7.1 — TenantSwitcher: the user's own organisations (F029.10)
|
|
57
101
|
|
|
58
102
|
0.7.0 was tagged but never reached npm: the secret gate stopped it on a test fixture key, now listed in `.gitleaksignore`.
|
package/css/app-shell.css
CHANGED
|
@@ -382,10 +382,47 @@
|
|
|
382
382
|
.bas-tenant__btn:hover, .bas-tenant__btn.is-open { background: var(--active); }
|
|
383
383
|
.bas-tenant__btn::after { content: "▾"; font-size: 10px; color: var(--fg-muted); flex: none; }
|
|
384
384
|
.bas-tenant__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; }
|
|
385
|
-
.bas-tenant__menu { left: 0; right: auto; }
|
|
385
|
+
.bas-tenant__menu { left: 0; right: auto; max-width: calc(100vw - 32px); }
|
|
386
|
+
/* phone: the org name gives way before the page title does */
|
|
387
|
+
@media (max-width: 767px) { .bas-tenant { max-width: 40vw; } }
|
|
386
388
|
.bas-tenant__item { justify-content: space-between; }
|
|
387
389
|
.bas-tenant__item.is-active { font-weight: 600; }
|
|
388
390
|
.bas-tenant__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
389
391
|
.bas-tenant__role { flex: none; font-size: 11.5px; color: var(--fg-muted); }
|
|
390
392
|
.bas-tenant__note { margin: 0; padding: 10px 14px; font-size: 12.5px; color: var(--fg-muted); }
|
|
391
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
|
@@ -1352,7 +1352,8 @@ function AnalyticsSeries({ data, t, locale, num }) {
|
|
|
1352
1352
|
] })
|
|
1353
1353
|
] });
|
|
1354
1354
|
}
|
|
1355
|
-
|
|
1355
|
+
var EDGE = 16;
|
|
1356
|
+
function TenantSwitcher({ lang, activeSlug, adapter, onSwitch, roleLabel }) {
|
|
1356
1357
|
const t = TEXT[lang];
|
|
1357
1358
|
const api = hooks.useMemo(() => adapter ?? createFetchTenantAdapter(), [adapter]);
|
|
1358
1359
|
const [rows, setRows] = hooks.useState(null);
|
|
@@ -1361,6 +1362,18 @@ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }) {
|
|
|
1361
1362
|
const [busy, setBusy] = hooks.useState(null);
|
|
1362
1363
|
const [switchFailed, setSwitchFailed] = hooks.useState(false);
|
|
1363
1364
|
const root = hooks.useRef(null);
|
|
1365
|
+
const menu = hooks.useRef(null);
|
|
1366
|
+
hooks.useLayoutEffect(() => {
|
|
1367
|
+
const el = menu.current;
|
|
1368
|
+
if (!open || !el) return;
|
|
1369
|
+
el.style.transform = "";
|
|
1370
|
+
const r = el.getBoundingClientRect();
|
|
1371
|
+
const vw = document.documentElement.clientWidth || window.innerWidth;
|
|
1372
|
+
let dx = 0;
|
|
1373
|
+
if (r.right > vw - EDGE) dx = vw - EDGE - r.right;
|
|
1374
|
+
if (r.left + dx < EDGE) dx = EDGE - r.left;
|
|
1375
|
+
if (dx !== 0) el.style.transform = `translateX(${Math.round(dx)}px)`;
|
|
1376
|
+
}, [open, rows, loadFailed, switchFailed]);
|
|
1364
1377
|
const load = () => {
|
|
1365
1378
|
setLoadFailed(false);
|
|
1366
1379
|
api.load().then(setRows, () => setLoadFailed(true));
|
|
@@ -1411,7 +1424,7 @@ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }) {
|
|
|
1411
1424
|
children: /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__label", "data-testid": "tenant-switcher-label", children: label })
|
|
1412
1425
|
}
|
|
1413
1426
|
),
|
|
1414
|
-
open ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-menu bas-tenant__menu", role: "menu", "data-testid": "tenant-switcher-menu", children: [
|
|
1427
|
+
open ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-menu bas-tenant__menu", ref: menu, role: "menu", "data-testid": "tenant-switcher-menu", children: [
|
|
1415
1428
|
loadFailed ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-tenant__note", role: "alert", "data-testid": "tenant-switcher-load-error", children: [
|
|
1416
1429
|
/* @__PURE__ */ jsxRuntime.jsx("span", { children: t.tenantLoadFailed }),
|
|
1417
1430
|
/* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", class: "bas-mi", onClick: load, "data-testid": "tenant-switcher-retry", children: t.tenantRetry })
|
|
@@ -1432,7 +1445,7 @@ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }) {
|
|
|
1432
1445
|
"data-testid": `tenant-switcher-item-${r.tenant.slug}`,
|
|
1433
1446
|
children: [
|
|
1434
1447
|
/* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__name", children: r.tenant.name }),
|
|
1435
|
-
/* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__role", children: suspended ? t.tenantSuspended : isActive ? t.tenantActive : r.role })
|
|
1448
|
+
suspended || isActive || roleLabel ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__role", "data-testid": `tenant-switcher-role-${r.tenant.slug}`, children: suspended ? t.tenantSuspended : isActive ? t.tenantActive : roleLabel(r.role) }) : null
|
|
1436
1449
|
]
|
|
1437
1450
|
},
|
|
1438
1451
|
r.tenant.id
|
|
@@ -1441,6 +1454,97 @@ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }) {
|
|
|
1441
1454
|
] }) : null
|
|
1442
1455
|
] });
|
|
1443
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
|
+
}
|
|
1444
1548
|
|
|
1445
1549
|
exports.AVATAR_MAX_BYTES = AVATAR_MAX_BYTES;
|
|
1446
1550
|
exports.AVATAR_TYPES = AVATAR_TYPES;
|
|
@@ -1450,6 +1554,7 @@ exports.AnalyticsError = AnalyticsError;
|
|
|
1450
1554
|
exports.AnalyticsPage = AnalyticsPage;
|
|
1451
1555
|
exports.AppShell = AppShell;
|
|
1452
1556
|
exports.Avatar = Avatar;
|
|
1557
|
+
exports.ConfirmDialog = ConfirmDialog;
|
|
1453
1558
|
exports.NotificationBell = NotificationBell;
|
|
1454
1559
|
exports.PageHeader = PageHeader;
|
|
1455
1560
|
exports.PageTabs = PageTabs;
|