@broberg/app-shell 0.1.0 → 0.2.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 +45 -4
- package/css/app-shell.css +55 -24
- package/dist/index.cjs +30 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/preact.cjs +192 -22
- package/dist/preact.cjs.map +1 -1
- package/dist/preact.d.cts +60 -5
- package/dist/preact.d.ts +60 -5
- package/dist/preact.js +191 -23
- package/dist/preact.js.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -22,6 +22,10 @@ const notifications = createFetchNotificationSource({ url: "/api/notifications"
|
|
|
22
22
|
user={{ name, email, picture }} // picture = Broberg ID's OIDC `picture`
|
|
23
23
|
userMenu={{ accountHref: "https://id.broberg.ai/account", onSignOut }}
|
|
24
24
|
mobile="drawer" // or "rail"
|
|
25
|
+
collapse="icon" // desktop fold: "offcanvas" (default) or an icon rail
|
|
26
|
+
brandMark={<LogoIcon />} // top of the icon rail
|
|
27
|
+
homeHref="/" // brand + brandMark link to the start page
|
|
28
|
+
title="Dokumenter" // optional: else the active nav item's label
|
|
25
29
|
lang="da"
|
|
26
30
|
>
|
|
27
31
|
<PageHeader eyebrow="Settings" title="Indstillinger" />
|
|
@@ -37,18 +41,47 @@ the user menu reads and writes theme, palette, surfaces and backdrop through it.
|
|
|
37
41
|
|
|
38
42
|
| Export | What it is |
|
|
39
43
|
|---|---|
|
|
40
|
-
| `AppShell` | Top bar + sidebar + `<main
|
|
41
|
-
| `Sidebar` | Groups (foldable, remembered) + footer, from data. Active item from `currentPath`. Badges, 99+. |
|
|
44
|
+
| `AppShell` | Top bar + sidebar + `<main>` with a content header (sidebar button · rule · page title, as shadcn dashboard-01); remembers the fold, folded groups and open items (`storageKey`). |
|
|
45
|
+
| `Sidebar` | Groups (foldable, remembered) + footer, from data. Items can have `children` (one level) that open and close. Active item from `currentPath`. Badges, 99+. |
|
|
42
46
|
| `TopBar` | Brand left, `actions` slot, then **the bell and the user menu, always last, in that order**. |
|
|
43
47
|
| `NotificationBell` | Unread count, panel (dropdown on desktop, bottom sheet on a phone), mark one / all read. |
|
|
44
48
|
| `UserMenu` | Avatar, name + email, **Konto** → Broberg ID, your items, Theme · Palette · Surfaces · Backdrop, sign out. |
|
|
45
49
|
| `PageHeader`, `PageTabs` | Eyebrow/title/description/actions; tabs as routes (`?tab=` or one path per tab). |
|
|
46
50
|
| `Avatar` | `picture` when given, otherwise initials. |
|
|
47
51
|
|
|
52
|
+
## 0.2.0 — one sidebar button, sub-items, an icon rail, the logo goes home (F092.4–.7)
|
|
53
|
+
|
|
54
|
+
**Breaking:** the three sidebar buttons are now ONE. `sidebar-collapse` («<» in
|
|
55
|
+
the sidebar), `sidebar-expand` («>» when folded) and `sidebar-toggle` (the
|
|
56
|
+
hamburger) are gone; `Sidebar` no longer takes `onToggleCollapse`.
|
|
57
|
+
|
|
58
|
+
- **The sidebar button** (`sidebar-trigger`, lucide `PanelLeft`, 16px in a 28px
|
|
59
|
+
ghost button) sits at the far left of a content header at the top of `<main>`,
|
|
60
|
+
then a 16px rule, then the page title as `<h1>` — shadcn dashboard-01's
|
|
61
|
+
site-header. Desktop: folds the sidebar, remembered. Phone: opens the drawer.
|
|
62
|
+
`aria-expanded` and `aria-label` follow the state.
|
|
63
|
+
- **The title** is `title`, else the label of the active nav item (a sub-item
|
|
64
|
+
beats its parent). No title → no empty `<h1>`. If you also render
|
|
65
|
+
`PageHeader`, that is a second `<h1>`; use one or the other as your page title.
|
|
66
|
+
- **Sub-items:** `NavItem.children` (one level). The parent is a button with a
|
|
67
|
+
chevron that turns 90° (`sidebar-item-<id>-toggle`, `aria-expanded`); the
|
|
68
|
+
children sit indented behind a thin rule. Open items are remembered; the
|
|
69
|
+
parent of the current page starts open and is marked.
|
|
70
|
+
- **`collapse="icon"`** folds to a 48px rail instead of hiding: `brandMark` on
|
|
71
|
+
top, each item's icon with its label as `aria-label` and tooltip, the current
|
|
72
|
+
page marked. A parent opens its children in a flyout
|
|
73
|
+
(`sidebar-flyout-<id>`), closed by Escape or a click outside. Default:
|
|
74
|
+
`"icon"` when `mobile="rail"`, else `"offcanvas"`.
|
|
75
|
+
- **`homeHref`** makes the brand (top bar and drawer) and the `brandMark` links
|
|
76
|
+
to the start page (`brand-home`, `sidebar-brand-home`, `brand-mark-home`),
|
|
77
|
+
through `onNavigate`; cmd/ctrl-click opens a tab as usual, and on a phone the
|
|
78
|
+
click closes the drawer. `homeLabel` names the icon-only `brandMark` link
|
|
79
|
+
(default «Forside»/«Home»).
|
|
80
|
+
|
|
48
81
|
## Phone: `mobile="drawer"` or `mobile="rail"`
|
|
49
82
|
|
|
50
|
-
- **drawer** (cardmem):
|
|
51
|
-
- **rail** (trail): the sidebar is a 60px icon rail;
|
|
83
|
+
- **drawer** (cardmem): the sidebar button opens the sidebar over a backdrop; a tap on the backdrop or on a link closes it.
|
|
84
|
+
- **rail** (trail): the sidebar is a 60px icon rail; the sidebar button is hidden (there is nothing to fold).
|
|
52
85
|
|
|
53
86
|
## Notifications are standard — on @broberg/notifications
|
|
54
87
|
|
|
@@ -75,6 +108,14 @@ notifikationer»** — never «no notifications», because those two must not lo
|
|
|
75
108
|
the same. `createMemoryNotificationSource(rows)` is for demos and tests; write
|
|
76
109
|
your own `NotificationSource` for SSE or anything else.
|
|
77
110
|
|
|
111
|
+
### A notification link is server data (0.1.1)
|
|
112
|
+
|
|
113
|
+
A row's `navigate` comes from your server and ends in `location.assign` (or your
|
|
114
|
+
`onNavigate`). Only a same-site path (`/…`, not `//…`) or an `http(s)` URL is
|
|
115
|
+
followed; `javascript:`, `data:` and anything else is refused, and the click only
|
|
116
|
+
marks the row seen. `safeNavigate(target)` is exported if you route links yourself.
|
|
117
|
+
0.1.0 followed any value — upgrade.
|
|
118
|
+
|
|
78
119
|
## `lang` is required
|
|
79
120
|
|
|
80
121
|
Every component that shows text takes `lang: "da" | "en"` with no default — the
|
package/css/app-shell.css
CHANGED
|
@@ -64,7 +64,9 @@
|
|
|
64
64
|
.bas-sidebar__brand { min-width: 0; }
|
|
65
65
|
/* drawer mode: on desktop the brand already sits in the top bar — show it in the sidebar only on a phone */
|
|
66
66
|
.bas-root[data-mobile="drawer"] .bas-sidebar__brand { display: none; }
|
|
67
|
-
.bas-
|
|
67
|
+
.bas-sidebar__mark { display: none; }
|
|
68
|
+
.bas-home { display: inline-flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; border-radius: var(--bas-radius); }
|
|
69
|
+
.bas-home:hover { opacity: .85; }
|
|
68
70
|
.bas-sidebar__nav { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 8px 8px; }
|
|
69
71
|
.bas-sidebar__foot { border-top: 1px solid var(--border); padding: 8px; }
|
|
70
72
|
.bas-group { margin-bottom: 6px; }
|
|
@@ -96,23 +98,55 @@
|
|
|
96
98
|
background: var(--accent-soft); color: var(--fg);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
/*
|
|
100
|
-
.bas-
|
|
101
|
+
/* ── items with children (F092.5) ── */
|
|
102
|
+
.bas-item--parent { border: 0; background: transparent; text-align: left; cursor: pointer; }
|
|
103
|
+
.bas-item--parent.is-parent-active { color: var(--fg); }
|
|
104
|
+
.bas-item--parent.is-parent-active .bas-item__icon { color: var(--accent); }
|
|
105
|
+
.bas-item__chev { display: inline-flex; flex: none; margin-left: auto; color: var(--fg-subtle); }
|
|
106
|
+
.bas-sub {
|
|
107
|
+
display: flex; flex-direction: column; gap: 1px;
|
|
108
|
+
margin: 2px 0 4px 17px; padding-left: 8px; border-left: 1px solid var(--border);
|
|
109
|
+
}
|
|
110
|
+
.bas-item--sub { padding: 6px 8px; }
|
|
111
|
+
|
|
112
|
+
/* ── the rail's flyout (F092.6) ── */
|
|
113
|
+
.bas-flyout {
|
|
114
|
+
position: fixed; z-index: 70; min-width: 190px; padding: 6px;
|
|
115
|
+
display: flex; flex-direction: column; gap: 1px;
|
|
116
|
+
background: var(--bg-card, var(--bg)); color: var(--fg);
|
|
117
|
+
border: 1px solid var(--border); border-radius: var(--bas-radius);
|
|
118
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, .18);
|
|
119
|
+
}
|
|
120
|
+
.bas-flyout__title { padding: 4px 8px 6px; font-size: 12px; font-weight: 500; color: var(--fg-subtle); }
|
|
121
|
+
.bas-flyout .bas-item__label { display: block; }
|
|
122
|
+
|
|
123
|
+
/* ── the rail: phone rail mode, or desktop folded with collapse="icon" ── */
|
|
124
|
+
.bas-sidebar.is-railed .bas-item__label,
|
|
125
|
+
.bas-sidebar.is-railed .bas-item__chev,
|
|
126
|
+
.bas-sidebar.is-railed .bas-group__label,
|
|
127
|
+
.bas-sidebar.is-railed .bas-sidebar__brand { display: none; }
|
|
128
|
+
.bas-sidebar.is-railed .bas-sidebar__nav { padding: 4px 6px 8px; }
|
|
129
|
+
.bas-sidebar.is-railed .bas-sidebar__foot { padding: 8px 6px; }
|
|
130
|
+
.bas-sidebar.is-railed .bas-item { justify-content: center; padding: 8px 0; }
|
|
131
|
+
.bas-sidebar.is-railed .bas-item__bar { left: -6px; }
|
|
132
|
+
.bas-sidebar.is-railed .bas-item__count { position: absolute; top: 2px; right: 2px; padding: 0 4px; }
|
|
133
|
+
.bas-sidebar.is-railed .bas-sidebar__head { justify-content: center; padding: 10px 0 4px; }
|
|
134
|
+
.bas-sidebar.is-railed .bas-sidebar__mark { display: flex; align-items: center; justify-content: center; }
|
|
135
|
+
|
|
136
|
+
/* Desktop fold: "offcanvas" hides the sidebar, "icon" keeps a 48px rail. */
|
|
101
137
|
@media (min-width: 768px) {
|
|
102
|
-
.bas-sidebar
|
|
103
|
-
.bas-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
.bas-sidebar[data-collapse="offcanvas"].is-collapsed { flex-basis: 0; width: 0; border-right: 0; }
|
|
139
|
+
.bas-sidebar[data-collapse="icon"].is-collapsed { flex-basis: 48px; width: 48px; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ── content header: the sidebar button, a rule, the page title (F092.4, shadcn dashboard-01) ── */
|
|
143
|
+
.bas-contenthead {
|
|
144
|
+
display: flex; align-items: center; gap: 4px; height: 48px; padding: 0 16px;
|
|
145
|
+
border-bottom: 1px solid var(--border);
|
|
108
146
|
}
|
|
109
|
-
.bas-
|
|
110
|
-
.bas-
|
|
111
|
-
.bas-
|
|
112
|
-
.bas-sidebar.is-mobile-rail.is-collapsed .bas-sidebar__brand { display: none; }
|
|
113
|
-
.bas-sidebar.is-mobile-rail.is-collapsed .bas-item { justify-content: center; padding: 8px 0; }
|
|
114
|
-
.bas-sidebar.is-mobile-rail.is-collapsed .bas-item__count { position: absolute; top: 2px; right: 4px; padding: 0 4px; }
|
|
115
|
-
.bas-sidebar.is-mobile-rail.is-collapsed .bas-collapse { transform: rotate(180deg); }
|
|
147
|
+
.bas-trigger { width: 28px; height: 28px; margin-left: -4px; }
|
|
148
|
+
.bas-contenthead__sep { flex: none; width: 1px; height: 16px; margin: 0 8px; background: var(--border); }
|
|
149
|
+
.bas-contenthead__title { margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 500; color: var(--fg); }
|
|
116
150
|
|
|
117
151
|
.bas-backdrop { display: none; }
|
|
118
152
|
|
|
@@ -131,17 +165,14 @@
|
|
|
131
165
|
box-shadow: 0 24px 56px rgba(0, 0, 0, .22);
|
|
132
166
|
}
|
|
133
167
|
.bas-root[data-mobile="drawer"] .bas-sidebar__brand { display: block; }
|
|
134
|
-
.bas-sidebar.is-mobile-drawer .bas-collapse { display: none; }
|
|
135
168
|
.bas-backdrop.is-open { display: block; position: fixed; inset: 0; z-index: 55; background: rgba(0, 0, 0, .36); }
|
|
136
169
|
|
|
137
|
-
/* rail: always the 60px icon rail on a phone, never a drawer */
|
|
170
|
+
/* rail: always the 60px icon rail on a phone, never a drawer (styling via .is-railed) */
|
|
138
171
|
.bas-sidebar.is-mobile-rail { flex-basis: 60px; width: 60px; }
|
|
139
|
-
|
|
140
|
-
.bas-
|
|
141
|
-
.bas-
|
|
142
|
-
.bas-
|
|
143
|
-
.bas-sidebar.is-mobile-rail .bas-item { justify-content: center; padding: 8px 0; }
|
|
144
|
-
.bas-sidebar.is-mobile-rail .bas-item__count { position: absolute; top: 2px; right: 4px; padding: 0 4px; }
|
|
172
|
+
/* the sidebar button has nothing to fold on a phone in rail mode */
|
|
173
|
+
.bas-root[data-mobile="rail"] .bas-trigger,
|
|
174
|
+
.bas-root[data-mobile="rail"] .bas-contenthead__sep { display: none; }
|
|
175
|
+
.bas-contenthead { padding: 0 12px; }
|
|
145
176
|
.bas-topbar__inner { padding: 0 12px; gap: 8px; }
|
|
146
177
|
.bas-userbtn__name { display: none; }
|
|
147
178
|
}
|
package/dist/index.cjs
CHANGED
|
@@ -7,6 +7,30 @@ function isActivePath(href, currentPath) {
|
|
|
7
7
|
if (target === "/") return path === "/";
|
|
8
8
|
return path === target || path.startsWith(target + "/");
|
|
9
9
|
}
|
|
10
|
+
function safeNavigate(target) {
|
|
11
|
+
if (typeof target !== "string") return null;
|
|
12
|
+
const t = target.trim();
|
|
13
|
+
if (t.startsWith("/") && !t.startsWith("//") && !t.startsWith("/\\")) return t;
|
|
14
|
+
try {
|
|
15
|
+
const u = new URL(t);
|
|
16
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function activeNavLabel(groups, footer, currentPath) {
|
|
22
|
+
let best;
|
|
23
|
+
const visit = (it) => {
|
|
24
|
+
if (it.href && isActivePath(it.href, currentPath)) {
|
|
25
|
+
const len = it.href.length;
|
|
26
|
+
if (!best || len > best.len) best = { label: it.label, len };
|
|
27
|
+
}
|
|
28
|
+
it.children?.forEach(visit);
|
|
29
|
+
};
|
|
30
|
+
for (const g of groups) g.items.forEach(visit);
|
|
31
|
+
footer?.forEach(visit);
|
|
32
|
+
return best?.label;
|
|
33
|
+
}
|
|
10
34
|
function badgeLabel(n) {
|
|
11
35
|
if (!n || n <= 0) return null;
|
|
12
36
|
return n > 99 ? "99+" : String(n);
|
|
@@ -141,6 +165,8 @@ function relativeTime(ts, lang, now = Date.now()) {
|
|
|
141
165
|
var TEXT = {
|
|
142
166
|
da: {
|
|
143
167
|
openMenu: "\xC5bn menu",
|
|
168
|
+
closeMenu: "Luk menu",
|
|
169
|
+
home: "Forside",
|
|
144
170
|
collapse: "Skjul sidemenu",
|
|
145
171
|
expand: "Vis sidemenu",
|
|
146
172
|
notifications: "Notifikationer",
|
|
@@ -165,6 +191,8 @@ var TEXT = {
|
|
|
165
191
|
},
|
|
166
192
|
en: {
|
|
167
193
|
openMenu: "Open menu",
|
|
194
|
+
closeMenu: "Close menu",
|
|
195
|
+
home: "Home",
|
|
168
196
|
collapse: "Hide sidebar",
|
|
169
197
|
expand: "Show sidebar",
|
|
170
198
|
notifications: "Notifications",
|
|
@@ -190,6 +218,7 @@ var TEXT = {
|
|
|
190
218
|
};
|
|
191
219
|
|
|
192
220
|
exports.TEXT = TEXT;
|
|
221
|
+
exports.activeNavLabel = activeNavLabel;
|
|
193
222
|
exports.badgeLabel = badgeLabel;
|
|
194
223
|
exports.createFetchNotificationSource = createFetchNotificationSource;
|
|
195
224
|
exports.createMemoryNotificationSource = createMemoryNotificationSource;
|
|
@@ -198,6 +227,7 @@ exports.isActivePath = isActivePath;
|
|
|
198
227
|
exports.readFlag = readFlag;
|
|
199
228
|
exports.readSet = readSet;
|
|
200
229
|
exports.relativeTime = relativeTime;
|
|
230
|
+
exports.safeNavigate = safeNavigate;
|
|
201
231
|
exports.writeFlag = writeFlag;
|
|
202
232
|
exports.writeSet = writeSet;
|
|
203
233
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/app-shell — the framework-free core of the fleet's app shell (F092).\n//\n// The shell every app in the fleet had been copying: sidebar + top bar +\n// notification bell + user menu. cardmem and trail each had one (\"ported from\n// trail\"), and helpdesk and the appkit starter would have made the third and\n// fourth. This file holds what does not depend on a framework: the navigation\n// data model, the notification contract and its sources, and the small rules\n// both the Preact and React parts must agree on.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template, not\n// an optional slot — so the contract and a default source live here, and a new\n// app gets a working notification centre by pointing one URL at its backend.\n\n// ── Navigation ─────────────────────────────────────────────────────────────\n\n/** One sidebar entry. `icon` is whatever your framework renders (an element). */\nexport interface NavItem<Icon = unknown> {\n id: string;\n label: string;\n href: string;\n icon?: Icon;\n /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */\n badge?: number;\n}\nexport interface NavGroup<Icon = unknown> {\n label: string;\n items: NavItem<Icon>[];\n}\n\n/**\n * Is this item the current page? Exact match for \"/\", otherwise the path is the\n * item or below it (\"/board/123\" is on \"/board\", \"/boardroom\" is not).\n */\nexport function isActivePath(href: string, currentPath: string): boolean {\n const path = (currentPath.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n const target = (href.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n if (target === \"/\") return path === \"/\";\n return path === target || path.startsWith(target + \"/\");\n}\n\n/** \"99+\" above 99, nothing for 0 or less. */\nexport function badgeLabel(n: number | undefined): string | null {\n if (!n || n <= 0) return null;\n return n > 99 ? \"99+\" : String(n);\n}\n\n// ── Persisted UI state ─────────────────────────────────────────────────────\n\n/** Read a boolean flag from localStorage; storage errors read as `fallback`. */\nexport function readFlag(key: string, fallback: boolean): boolean {\n try {\n const v = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n return v === null ? fallback : v === \"1\";\n } catch {\n return fallback;\n }\n}\nexport function writeFlag(key: string, value: boolean): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, value ? \"1\" : \"0\");\n } catch {\n /* private mode / quota: the UI still works, it just does not remember */\n }\n}\nexport function readSet(key: string): Set<string> {\n try {\n const raw = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n const v = raw ? (JSON.parse(raw) as unknown) : [];\n return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === \"string\") : []);\n } catch {\n return new Set();\n }\n}\nexport function writeSet(key: string, value: Set<string>): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, JSON.stringify([...value]));\n } catch {\n /* see writeFlag */\n }\n}\n\n// ── Avatar ─────────────────────────────────────────────────────────────────\n\n/**\n * Two initials for the avatar fallback: first letter of the first and the last\n * word, upper-cased. One word gives one letter; nothing gives \"?\".\n *\n * There is deliberately no Gravatar here (owner decision D-1dc849): the avatar\n * is the Broberg ID `picture`, and without one, initials. Gravatar would send a\n * hash of the user's email to a US service on every render.\n */\nexport function initials(name: string | undefined, email?: string): string {\n const source = (name ?? \"\").trim() || (email ?? \"\").split(\"@\")[0] || \"\";\n const words = source.split(/[\\s._-]+/).filter(Boolean);\n if (words.length === 0) return \"?\";\n const first = words[0]![0] ?? \"\";\n const last = words.length > 1 ? words[words.length - 1]![0] ?? \"\" : \"\";\n return (first + last).toUpperCase() || \"?\";\n}\n\n// ── Notifications ──────────────────────────────────────────────────────────\n//\n// REUSE, NOT A SECOND CONTRACT. The fleet already agreed the notification shape\n// in @broberg/notifications (F074): `NotificationRow` — the same field names\n// @broberg/webpush sends, end to end — and the server core that owns the ONE\n// counting rule. The shell's bell renders that row and drives its panel with\n// that package's `createBellShell`, so a server built on `createNotifications`\n// and this bell speak the same language without an adapter.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template. So\n// a source, and a default one, ship here: point one URL at your backend.\n\nimport type { NotificationRow } from \"@broberg/notifications/types\";\nexport type { NotificationRow } from \"@broberg/notifications/types\";\n\n/** Where the bell gets its data. Swap it to point at any backend. */\nexport interface NotificationSource {\n /** The rows to show (newest first). */\n loadRows(): Promise<NotificationRow[]>;\n /** THE count — your server's, which may exclude muted kinds (F074's counting rule). */\n countUnseen(): Promise<number>;\n /** Returns the ids that actually transitioned to seen. */\n markSeen(ids: readonly string[]): Promise<string[]>;\n markAllSeen(): Promise<string[]>;\n /** Called with a callback that re-counts; return an unsubscribe. Optional. */\n subscribe?(onChange: () => void): () => void;\n}\n\n/**\n * The default source: a URL on your own backend, shaped after\n * @broberg/notifications' server core so `createNotifications` sits right\n * behind it.\n *\n * GET {url} → { items: NotificationRow[], unseen: number }\n * POST {url}/seen { ids: string[] } → { clearedIds: string[], count: number }\n * POST {url}/seen-all → { clearedIds: string[], count: number }\n *\n * It re-counts when the window regains focus or the tab becomes visible, and on\n * a timer (default 60 s). A failed GET THROWS — the bell shows «could not\n * load», never an empty list: \"could not fetch\" and \"nothing new\" must not look\n * the same.\n */\nexport function createFetchNotificationSource(opts: {\n url: string;\n fetch?: typeof fetch;\n /** Re-count interval in ms. 0 turns it off. Default 60 000. */\n pollMs?: number;\n credentials?: RequestCredentials;\n}): NotificationSource {\n const base = opts.url.replace(/\\/+$/, \"\");\n const doFetch = (input: string, init?: RequestInit) =>\n (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? \"include\", ...init });\n const get = async (): Promise<{ items: NotificationRow[]; unseen: number }> => {\n const res = await doFetch(base, { headers: { accept: \"application/json\" } });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const body = (await res.json().catch(() => null)) as { items?: unknown; unseen?: unknown } | null;\n if (!body || !Array.isArray(body.items)) throw new Error(\"response has no items array\");\n const items = body.items as NotificationRow[];\n const unseen = typeof body.unseen === \"number\" ? body.unseen : items.filter((n) => n.seenAt == null).length;\n return { items, unseen };\n };\n const post = async (path: string, body?: unknown): Promise<string[]> => {\n const res = await doFetch(`${base}${path}`, {\n method: \"POST\",\n headers: body === undefined ? undefined : { \"content-type\": \"application/json\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const r = (await res.json().catch(() => ({}))) as { clearedIds?: unknown };\n return Array.isArray(r.clearedIds) ? (r.clearedIds as string[]) : [];\n };\n return {\n loadRows: async () => (await get()).items,\n countUnseen: async () => (await get()).unseen,\n markSeen: (ids) => post(\"/seen\", { ids }),\n markAllSeen: () => post(\"/seen-all\"),\n subscribe(onChange) {\n if (typeof window === \"undefined\") return () => {};\n const onVisible = () => {\n if (document.visibilityState === \"visible\") onChange();\n };\n window.addEventListener(\"focus\", onChange);\n document.addEventListener(\"visibilitychange\", onVisible);\n const ms = opts.pollMs ?? 60_000;\n const timer = ms > 0 ? setInterval(onChange, ms) : null;\n return () => {\n window.removeEventListener(\"focus\", onChange);\n document.removeEventListener(\"visibilitychange\", onVisible);\n if (timer) clearInterval(timer);\n };\n },\n };\n}\n\n/** An in-memory source for demos and tests. */\nexport function createMemoryNotificationSource(initial: NotificationRow[] = []): NotificationSource & {\n push(n: NotificationRow): void;\n rows(): NotificationRow[];\n} {\n let rows = initial.map((n) => ({ ...n }));\n const listeners = new Set<() => void>();\n const emit = () => listeners.forEach((l) => l());\n const mark = (pred: (n: NotificationRow) => boolean): string[] => {\n const now = Date.now();\n const cleared: string[] = [];\n rows = rows.map((n) => {\n if (n.seenAt == null && pred(n)) {\n cleared.push(n.id);\n return { ...n, seenAt: now };\n }\n return n;\n });\n if (cleared.length) emit();\n return cleared;\n };\n return {\n loadRows: async () => rows.map((n) => ({ ...n })),\n countUnseen: async () => rows.filter((n) => n.seenAt == null).length,\n markSeen: async (ids) => mark((n) => ids.includes(n.id)),\n markAllSeen: async () => mark(() => true),\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n push(n) {\n rows = [n, ...rows];\n emit();\n },\n rows: () => rows.map((n) => ({ ...n })),\n };\n}\n\n/** \"nu\", \"5 min\", \"3 t\", \"i går\", \"4 d\" (da) or the English equivalents. */\nexport function relativeTime(ts: string | number, lang: \"da\" | \"en\", now = Date.now()): string {\n const ms = typeof ts === \"number\" ? ts : Date.parse(ts);\n if (!Number.isFinite(ms)) return \"\";\n const m = Math.floor((now - ms) / 60_000);\n if (m < 1) return lang === \"da\" ? \"nu\" : \"now\";\n if (m < 60) return `${m} min`;\n const h = Math.floor(m / 60);\n if (h < 24) return lang === \"da\" ? `${h} t` : `${h} h`;\n const d = Math.floor(h / 24);\n if (d === 1) return lang === \"da\" ? \"i går\" : \"yesterday\";\n return `${d} d`;\n}\n\n// ── Text ───────────────────────────────────────────────────────────────────\n\nexport const TEXT = {\n da: {\n openMenu: \"Åbn menu\",\n collapse: \"Skjul sidemenu\",\n expand: \"Vis sidemenu\",\n notifications: \"Notifikationer\",\n markAllRead: \"Markér alle læst\",\n noNotifications: \"Ingen notifikationer endnu.\",\n couldNotLoad: \"Kunne ikke hente notifikationer.\",\n seeAll: \"Vis alle\",\n close: \"Luk\",\n account: \"Konto\",\n signOut: \"Log ud\",\n theme: \"Tema\",\n light: \"Lys\",\n dark: \"Mørk\",\n system: \"System\",\n palette: \"Palet\",\n surfaces: \"Flader\",\n flat: \"Flade\",\n layered: \"Lag\",\n backdrop: \"Baggrund\",\n neurons: \"Neuroner\",\n plain: \"Ren\",\n },\n en: {\n openMenu: \"Open menu\",\n collapse: \"Hide sidebar\",\n expand: \"Show sidebar\",\n notifications: \"Notifications\",\n markAllRead: \"Mark all read\",\n noNotifications: \"No notifications yet.\",\n couldNotLoad: \"Could not load notifications.\",\n seeAll: \"See all\",\n close: \"Close\",\n account: \"Account\",\n signOut: \"Sign out\",\n theme: \"Theme\",\n light: \"Light\",\n dark: \"Dark\",\n system: \"System\",\n palette: \"Palette\",\n surfaces: \"Surfaces\",\n flat: \"Flat\",\n layered: \"Layered\",\n backdrop: \"Backdrop\",\n neurons: \"Neurons\",\n plain: \"Plain\",\n },\n} as const;\nexport type Lang = keyof typeof TEXT;\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/app-shell — the framework-free core of the fleet's app shell (F092).\n//\n// The shell every app in the fleet had been copying: sidebar + top bar +\n// notification bell + user menu. cardmem and trail each had one (\"ported from\n// trail\"), and helpdesk and the appkit starter would have made the third and\n// fourth. This file holds what does not depend on a framework: the navigation\n// data model, the notification contract and its sources, and the small rules\n// both the Preact and React parts must agree on.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template, not\n// an optional slot — so the contract and a default source live here, and a new\n// app gets a working notification centre by pointing one URL at its backend.\n\n// ── Navigation ─────────────────────────────────────────────────────────────\n\n/** One sidebar entry. `icon` is whatever your framework renders (an element). */\nexport interface NavItem<Icon = unknown> {\n id: string;\n label: string;\n href: string;\n icon?: Icon;\n /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */\n badge?: number;\n /**\n * Sub-items, ONE level (F092.5). An item with children is a toggle that opens\n * and closes them, not a link; its own `href` is used only to decide whether\n * it is on the current path.\n */\n children?: NavItem<Icon>[];\n}\nexport interface NavGroup<Icon = unknown> {\n label: string;\n items: NavItem<Icon>[];\n}\n\n/**\n * Is this item the current page? Exact match for \"/\", otherwise the path is the\n * item or below it (\"/board/123\" is on \"/board\", \"/boardroom\" is not).\n */\nexport function isActivePath(href: string, currentPath: string): boolean {\n const path = (currentPath.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n const target = (href.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n if (target === \"/\") return path === \"/\";\n return path === target || path.startsWith(target + \"/\");\n}\n\n/**\n * A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.\n * `javascript:` there runs code in the app, so only a same-site path or an\n * http(s) URL is followed; anything else returns null and the row just closes.\n */\nexport function safeNavigate(target: string | null | undefined): string | null {\n if (typeof target !== \"string\") return null;\n const t = target.trim();\n if (t.startsWith(\"/\") && !t.startsWith(\"//\") && !t.startsWith(\"/\\\\\")) return t;\n try {\n const u = new URL(t);\n return u.protocol === \"https:\" || u.protocol === \"http:\" ? u.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The label of the nav item the user is on, for the content header (F092.4).\n * Sub-items count, and the most specific match wins: on \"/models/genesis\" a\n * \"Genesis\" child beats its \"Models\" parent.\n */\nexport function activeNavLabel<Icon>(\n groups: NavGroup<Icon>[],\n footer: NavItem<Icon>[] | undefined,\n currentPath: string,\n): string | undefined {\n let best: { label: string; len: number } | undefined;\n const visit = (it: NavItem<Icon>) => {\n if (it.href && isActivePath(it.href, currentPath)) {\n const len = it.href.length;\n if (!best || len > best.len) best = { label: it.label, len };\n }\n it.children?.forEach(visit);\n };\n for (const g of groups) g.items.forEach(visit);\n footer?.forEach(visit);\n return best?.label;\n}\n\n/** \"99+\" above 99, nothing for 0 or less. */\nexport function badgeLabel(n: number | undefined): string | null {\n if (!n || n <= 0) return null;\n return n > 99 ? \"99+\" : String(n);\n}\n\n// ── Persisted UI state ─────────────────────────────────────────────────────\n\n/** Read a boolean flag from localStorage; storage errors read as `fallback`. */\nexport function readFlag(key: string, fallback: boolean): boolean {\n try {\n const v = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n return v === null ? fallback : v === \"1\";\n } catch {\n return fallback;\n }\n}\nexport function writeFlag(key: string, value: boolean): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, value ? \"1\" : \"0\");\n } catch {\n /* private mode / quota: the UI still works, it just does not remember */\n }\n}\nexport function readSet(key: string): Set<string> {\n try {\n const raw = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n const v = raw ? (JSON.parse(raw) as unknown) : [];\n return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === \"string\") : []);\n } catch {\n return new Set();\n }\n}\nexport function writeSet(key: string, value: Set<string>): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, JSON.stringify([...value]));\n } catch {\n /* see writeFlag */\n }\n}\n\n// ── Avatar ─────────────────────────────────────────────────────────────────\n\n/**\n * Two initials for the avatar fallback: first letter of the first and the last\n * word, upper-cased. One word gives one letter; nothing gives \"?\".\n *\n * There is deliberately no Gravatar here (owner decision D-1dc849): the avatar\n * is the Broberg ID `picture`, and without one, initials. Gravatar would send a\n * hash of the user's email to a US service on every render.\n */\nexport function initials(name: string | undefined, email?: string): string {\n const source = (name ?? \"\").trim() || (email ?? \"\").split(\"@\")[0] || \"\";\n const words = source.split(/[\\s._-]+/).filter(Boolean);\n if (words.length === 0) return \"?\";\n const first = words[0]![0] ?? \"\";\n const last = words.length > 1 ? words[words.length - 1]![0] ?? \"\" : \"\";\n return (first + last).toUpperCase() || \"?\";\n}\n\n// ── Notifications ──────────────────────────────────────────────────────────\n//\n// REUSE, NOT A SECOND CONTRACT. The fleet already agreed the notification shape\n// in @broberg/notifications (F074): `NotificationRow` — the same field names\n// @broberg/webpush sends, end to end — and the server core that owns the ONE\n// counting rule. The shell's bell renders that row and drives its panel with\n// that package's `createBellShell`, so a server built on `createNotifications`\n// and this bell speak the same language without an adapter.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template. So\n// a source, and a default one, ship here: point one URL at your backend.\n\nimport type { NotificationRow } from \"@broberg/notifications/types\";\nexport type { NotificationRow } from \"@broberg/notifications/types\";\n\n/** Where the bell gets its data. Swap it to point at any backend. */\nexport interface NotificationSource {\n /** The rows to show (newest first). */\n loadRows(): Promise<NotificationRow[]>;\n /** THE count — your server's, which may exclude muted kinds (F074's counting rule). */\n countUnseen(): Promise<number>;\n /** Returns the ids that actually transitioned to seen. */\n markSeen(ids: readonly string[]): Promise<string[]>;\n markAllSeen(): Promise<string[]>;\n /** Called with a callback that re-counts; return an unsubscribe. Optional. */\n subscribe?(onChange: () => void): () => void;\n}\n\n/**\n * The default source: a URL on your own backend, shaped after\n * @broberg/notifications' server core so `createNotifications` sits right\n * behind it.\n *\n * GET {url} → { items: NotificationRow[], unseen: number }\n * POST {url}/seen { ids: string[] } → { clearedIds: string[], count: number }\n * POST {url}/seen-all → { clearedIds: string[], count: number }\n *\n * It re-counts when the window regains focus or the tab becomes visible, and on\n * a timer (default 60 s). A failed GET THROWS — the bell shows «could not\n * load», never an empty list: \"could not fetch\" and \"nothing new\" must not look\n * the same.\n */\nexport function createFetchNotificationSource(opts: {\n url: string;\n fetch?: typeof fetch;\n /** Re-count interval in ms. 0 turns it off. Default 60 000. */\n pollMs?: number;\n credentials?: RequestCredentials;\n}): NotificationSource {\n const base = opts.url.replace(/\\/+$/, \"\");\n const doFetch = (input: string, init?: RequestInit) =>\n (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? \"include\", ...init });\n const get = async (): Promise<{ items: NotificationRow[]; unseen: number }> => {\n const res = await doFetch(base, { headers: { accept: \"application/json\" } });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const body = (await res.json().catch(() => null)) as { items?: unknown; unseen?: unknown } | null;\n if (!body || !Array.isArray(body.items)) throw new Error(\"response has no items array\");\n const items = body.items as NotificationRow[];\n const unseen = typeof body.unseen === \"number\" ? body.unseen : items.filter((n) => n.seenAt == null).length;\n return { items, unseen };\n };\n const post = async (path: string, body?: unknown): Promise<string[]> => {\n const res = await doFetch(`${base}${path}`, {\n method: \"POST\",\n headers: body === undefined ? undefined : { \"content-type\": \"application/json\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const r = (await res.json().catch(() => ({}))) as { clearedIds?: unknown };\n return Array.isArray(r.clearedIds) ? (r.clearedIds as string[]) : [];\n };\n return {\n loadRows: async () => (await get()).items,\n countUnseen: async () => (await get()).unseen,\n markSeen: (ids) => post(\"/seen\", { ids }),\n markAllSeen: () => post(\"/seen-all\"),\n subscribe(onChange) {\n if (typeof window === \"undefined\") return () => {};\n const onVisible = () => {\n if (document.visibilityState === \"visible\") onChange();\n };\n window.addEventListener(\"focus\", onChange);\n document.addEventListener(\"visibilitychange\", onVisible);\n const ms = opts.pollMs ?? 60_000;\n const timer = ms > 0 ? setInterval(onChange, ms) : null;\n return () => {\n window.removeEventListener(\"focus\", onChange);\n document.removeEventListener(\"visibilitychange\", onVisible);\n if (timer) clearInterval(timer);\n };\n },\n };\n}\n\n/** An in-memory source for demos and tests. */\nexport function createMemoryNotificationSource(initial: NotificationRow[] = []): NotificationSource & {\n push(n: NotificationRow): void;\n rows(): NotificationRow[];\n} {\n let rows = initial.map((n) => ({ ...n }));\n const listeners = new Set<() => void>();\n const emit = () => listeners.forEach((l) => l());\n const mark = (pred: (n: NotificationRow) => boolean): string[] => {\n const now = Date.now();\n const cleared: string[] = [];\n rows = rows.map((n) => {\n if (n.seenAt == null && pred(n)) {\n cleared.push(n.id);\n return { ...n, seenAt: now };\n }\n return n;\n });\n if (cleared.length) emit();\n return cleared;\n };\n return {\n loadRows: async () => rows.map((n) => ({ ...n })),\n countUnseen: async () => rows.filter((n) => n.seenAt == null).length,\n markSeen: async (ids) => mark((n) => ids.includes(n.id)),\n markAllSeen: async () => mark(() => true),\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n push(n) {\n rows = [n, ...rows];\n emit();\n },\n rows: () => rows.map((n) => ({ ...n })),\n };\n}\n\n/** \"nu\", \"5 min\", \"3 t\", \"i går\", \"4 d\" (da) or the English equivalents. */\nexport function relativeTime(ts: string | number, lang: \"da\" | \"en\", now = Date.now()): string {\n const ms = typeof ts === \"number\" ? ts : Date.parse(ts);\n if (!Number.isFinite(ms)) return \"\";\n const m = Math.floor((now - ms) / 60_000);\n if (m < 1) return lang === \"da\" ? \"nu\" : \"now\";\n if (m < 60) return `${m} min`;\n const h = Math.floor(m / 60);\n if (h < 24) return lang === \"da\" ? `${h} t` : `${h} h`;\n const d = Math.floor(h / 24);\n if (d === 1) return lang === \"da\" ? \"i går\" : \"yesterday\";\n return `${d} d`;\n}\n\n// ── Text ───────────────────────────────────────────────────────────────────\n\nexport const TEXT = {\n da: {\n openMenu: \"Åbn menu\",\n closeMenu: \"Luk menu\",\n home: \"Forside\",\n collapse: \"Skjul sidemenu\",\n expand: \"Vis sidemenu\",\n notifications: \"Notifikationer\",\n markAllRead: \"Markér alle læst\",\n noNotifications: \"Ingen notifikationer endnu.\",\n couldNotLoad: \"Kunne ikke hente notifikationer.\",\n seeAll: \"Vis alle\",\n close: \"Luk\",\n account: \"Konto\",\n signOut: \"Log ud\",\n theme: \"Tema\",\n light: \"Lys\",\n dark: \"Mørk\",\n system: \"System\",\n palette: \"Palet\",\n surfaces: \"Flader\",\n flat: \"Flade\",\n layered: \"Lag\",\n backdrop: \"Baggrund\",\n neurons: \"Neuroner\",\n plain: \"Ren\",\n },\n en: {\n openMenu: \"Open menu\",\n closeMenu: \"Close menu\",\n home: \"Home\",\n collapse: \"Hide sidebar\",\n expand: \"Show sidebar\",\n notifications: \"Notifications\",\n markAllRead: \"Mark all read\",\n noNotifications: \"No notifications yet.\",\n couldNotLoad: \"Could not load notifications.\",\n seeAll: \"See all\",\n close: \"Close\",\n account: \"Account\",\n signOut: \"Sign out\",\n theme: \"Theme\",\n light: \"Light\",\n dark: \"Dark\",\n system: \"System\",\n palette: \"Palette\",\n surfaces: \"Surfaces\",\n flat: \"Flat\",\n layered: \"Layered\",\n backdrop: \"Backdrop\",\n neurons: \"Neurons\",\n plain: \"Plain\",\n },\n} as const;\nexport type Lang = keyof typeof TEXT;\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -9,6 +9,12 @@ interface NavItem<Icon = unknown> {
|
|
|
9
9
|
icon?: Icon;
|
|
10
10
|
/** A count shown on the item (99+ above 99). 0/undefined shows nothing. */
|
|
11
11
|
badge?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Sub-items, ONE level (F092.5). An item with children is a toggle that opens
|
|
14
|
+
* and closes them, not a link; its own `href` is used only to decide whether
|
|
15
|
+
* it is on the current path.
|
|
16
|
+
*/
|
|
17
|
+
children?: NavItem<Icon>[];
|
|
12
18
|
}
|
|
13
19
|
interface NavGroup<Icon = unknown> {
|
|
14
20
|
label: string;
|
|
@@ -19,6 +25,18 @@ interface NavGroup<Icon = unknown> {
|
|
|
19
25
|
* item or below it ("/board/123" is on "/board", "/boardroom" is not).
|
|
20
26
|
*/
|
|
21
27
|
declare function isActivePath(href: string, currentPath: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.
|
|
30
|
+
* `javascript:` there runs code in the app, so only a same-site path or an
|
|
31
|
+
* http(s) URL is followed; anything else returns null and the row just closes.
|
|
32
|
+
*/
|
|
33
|
+
declare function safeNavigate(target: string | null | undefined): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* The label of the nav item the user is on, for the content header (F092.4).
|
|
36
|
+
* Sub-items count, and the most specific match wins: on "/models/genesis" a
|
|
37
|
+
* "Genesis" child beats its "Models" parent.
|
|
38
|
+
*/
|
|
39
|
+
declare function activeNavLabel<Icon>(groups: NavGroup<Icon>[], footer: NavItem<Icon>[] | undefined, currentPath: string): string | undefined;
|
|
22
40
|
/** "99+" above 99, nothing for 0 or less. */
|
|
23
41
|
declare function badgeLabel(n: number | undefined): string | null;
|
|
24
42
|
/** Read a boolean flag from localStorage; storage errors read as `fallback`. */
|
|
@@ -79,6 +97,8 @@ declare function relativeTime(ts: string | number, lang: "da" | "en", now?: numb
|
|
|
79
97
|
declare const TEXT: {
|
|
80
98
|
readonly da: {
|
|
81
99
|
readonly openMenu: "Åbn menu";
|
|
100
|
+
readonly closeMenu: "Luk menu";
|
|
101
|
+
readonly home: "Forside";
|
|
82
102
|
readonly collapse: "Skjul sidemenu";
|
|
83
103
|
readonly expand: "Vis sidemenu";
|
|
84
104
|
readonly notifications: "Notifikationer";
|
|
@@ -103,6 +123,8 @@ declare const TEXT: {
|
|
|
103
123
|
};
|
|
104
124
|
readonly en: {
|
|
105
125
|
readonly openMenu: "Open menu";
|
|
126
|
+
readonly closeMenu: "Close menu";
|
|
127
|
+
readonly home: "Home";
|
|
106
128
|
readonly collapse: "Hide sidebar";
|
|
107
129
|
readonly expand: "Show sidebar";
|
|
108
130
|
readonly notifications: "Notifications";
|
|
@@ -128,4 +150,4 @@ declare const TEXT: {
|
|
|
128
150
|
};
|
|
129
151
|
type Lang = keyof typeof TEXT;
|
|
130
152
|
|
|
131
|
-
export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, writeFlag, writeSet };
|
|
153
|
+
export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ interface NavItem<Icon = unknown> {
|
|
|
9
9
|
icon?: Icon;
|
|
10
10
|
/** A count shown on the item (99+ above 99). 0/undefined shows nothing. */
|
|
11
11
|
badge?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Sub-items, ONE level (F092.5). An item with children is a toggle that opens
|
|
14
|
+
* and closes them, not a link; its own `href` is used only to decide whether
|
|
15
|
+
* it is on the current path.
|
|
16
|
+
*/
|
|
17
|
+
children?: NavItem<Icon>[];
|
|
12
18
|
}
|
|
13
19
|
interface NavGroup<Icon = unknown> {
|
|
14
20
|
label: string;
|
|
@@ -19,6 +25,18 @@ interface NavGroup<Icon = unknown> {
|
|
|
19
25
|
* item or below it ("/board/123" is on "/board", "/boardroom" is not).
|
|
20
26
|
*/
|
|
21
27
|
declare function isActivePath(href: string, currentPath: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.
|
|
30
|
+
* `javascript:` there runs code in the app, so only a same-site path or an
|
|
31
|
+
* http(s) URL is followed; anything else returns null and the row just closes.
|
|
32
|
+
*/
|
|
33
|
+
declare function safeNavigate(target: string | null | undefined): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* The label of the nav item the user is on, for the content header (F092.4).
|
|
36
|
+
* Sub-items count, and the most specific match wins: on "/models/genesis" a
|
|
37
|
+
* "Genesis" child beats its "Models" parent.
|
|
38
|
+
*/
|
|
39
|
+
declare function activeNavLabel<Icon>(groups: NavGroup<Icon>[], footer: NavItem<Icon>[] | undefined, currentPath: string): string | undefined;
|
|
22
40
|
/** "99+" above 99, nothing for 0 or less. */
|
|
23
41
|
declare function badgeLabel(n: number | undefined): string | null;
|
|
24
42
|
/** Read a boolean flag from localStorage; storage errors read as `fallback`. */
|
|
@@ -79,6 +97,8 @@ declare function relativeTime(ts: string | number, lang: "da" | "en", now?: numb
|
|
|
79
97
|
declare const TEXT: {
|
|
80
98
|
readonly da: {
|
|
81
99
|
readonly openMenu: "Åbn menu";
|
|
100
|
+
readonly closeMenu: "Luk menu";
|
|
101
|
+
readonly home: "Forside";
|
|
82
102
|
readonly collapse: "Skjul sidemenu";
|
|
83
103
|
readonly expand: "Vis sidemenu";
|
|
84
104
|
readonly notifications: "Notifikationer";
|
|
@@ -103,6 +123,8 @@ declare const TEXT: {
|
|
|
103
123
|
};
|
|
104
124
|
readonly en: {
|
|
105
125
|
readonly openMenu: "Open menu";
|
|
126
|
+
readonly closeMenu: "Close menu";
|
|
127
|
+
readonly home: "Home";
|
|
106
128
|
readonly collapse: "Hide sidebar";
|
|
107
129
|
readonly expand: "Show sidebar";
|
|
108
130
|
readonly notifications: "Notifications";
|
|
@@ -128,4 +150,4 @@ declare const TEXT: {
|
|
|
128
150
|
};
|
|
129
151
|
type Lang = keyof typeof TEXT;
|
|
130
152
|
|
|
131
|
-
export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, writeFlag, writeSet };
|
|
153
|
+
export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,30 @@ function isActivePath(href, currentPath) {
|
|
|
5
5
|
if (target === "/") return path === "/";
|
|
6
6
|
return path === target || path.startsWith(target + "/");
|
|
7
7
|
}
|
|
8
|
+
function safeNavigate(target) {
|
|
9
|
+
if (typeof target !== "string") return null;
|
|
10
|
+
const t = target.trim();
|
|
11
|
+
if (t.startsWith("/") && !t.startsWith("//") && !t.startsWith("/\\")) return t;
|
|
12
|
+
try {
|
|
13
|
+
const u = new URL(t);
|
|
14
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function activeNavLabel(groups, footer, currentPath) {
|
|
20
|
+
let best;
|
|
21
|
+
const visit = (it) => {
|
|
22
|
+
if (it.href && isActivePath(it.href, currentPath)) {
|
|
23
|
+
const len = it.href.length;
|
|
24
|
+
if (!best || len > best.len) best = { label: it.label, len };
|
|
25
|
+
}
|
|
26
|
+
it.children?.forEach(visit);
|
|
27
|
+
};
|
|
28
|
+
for (const g of groups) g.items.forEach(visit);
|
|
29
|
+
footer?.forEach(visit);
|
|
30
|
+
return best?.label;
|
|
31
|
+
}
|
|
8
32
|
function badgeLabel(n) {
|
|
9
33
|
if (!n || n <= 0) return null;
|
|
10
34
|
return n > 99 ? "99+" : String(n);
|
|
@@ -139,6 +163,8 @@ function relativeTime(ts, lang, now = Date.now()) {
|
|
|
139
163
|
var TEXT = {
|
|
140
164
|
da: {
|
|
141
165
|
openMenu: "\xC5bn menu",
|
|
166
|
+
closeMenu: "Luk menu",
|
|
167
|
+
home: "Forside",
|
|
142
168
|
collapse: "Skjul sidemenu",
|
|
143
169
|
expand: "Vis sidemenu",
|
|
144
170
|
notifications: "Notifikationer",
|
|
@@ -163,6 +189,8 @@ var TEXT = {
|
|
|
163
189
|
},
|
|
164
190
|
en: {
|
|
165
191
|
openMenu: "Open menu",
|
|
192
|
+
closeMenu: "Close menu",
|
|
193
|
+
home: "Home",
|
|
166
194
|
collapse: "Hide sidebar",
|
|
167
195
|
expand: "Show sidebar",
|
|
168
196
|
notifications: "Notifications",
|
|
@@ -187,6 +215,6 @@ var TEXT = {
|
|
|
187
215
|
}
|
|
188
216
|
};
|
|
189
217
|
|
|
190
|
-
export { TEXT, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, writeFlag, writeSet };
|
|
218
|
+
export { TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
|
|
191
219
|
//# sourceMappingURL=index.js.map
|
|
192
220
|
//# sourceMappingURL=index.js.map
|