@broberg/app-shell 0.10.0 → 0.11.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
@@ -46,13 +46,36 @@ the user menu reads and writes theme, palette, surfaces and backdrop through it.
46
46
  | `Sidebar` | Groups (foldable, remembered) + footer, from data. Items can have `children` (one level) that open and close. Active item from `currentPath`. Badges, 99+. |
47
47
  | `TopBar` | Brand left, `actions` slot, then **the bell and the user menu, always last, in that order**. |
48
48
  | `NotificationBell` | Unread count, panel (dropdown on desktop, bottom sheet on a phone), mark one / all read. |
49
- | `UserMenu` | Avatar, name + email, **Konto** → Broberg ID, your items, Theme · Palette · Surfaces · Backdrop, sign out. |
49
+ | `UserMenu` | Avatar, name + email, **Konto** → Broberg ID, your items, Theme · Palette · Surfaces (· Backdrop, opt-in), sign out. |
50
50
  | `PageHeader`, `PageTabs` | Eyebrow/title/description/actions; tabs as routes (`?tab=` or one path per tab). |
51
51
  | `Avatar` | `picture` when given, otherwise initials. |
52
52
  | `AccountPage` | The user's own name and picture, edited inside the app (0.4.0). |
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.11.0 — the user menu's Appearance, tidied (F001.23)
57
+
58
+ Christian 10/10, via appkit: «Neuroner … er reelt set kun til trail og cardmem» and «Paletten er ikke pæn».
59
+
60
+ - **Backdrop (Neurons · Plain) is opt-in.** It only means something in an app
61
+ that draws the neuron canvas (`mountConstellation` from `@broberg/theme`).
62
+ **trail and cardmem: add one line to keep it:**
63
+ ```tsx
64
+ <AppShell userMenu={{ appearance: { backdrop: true } }} … />
65
+ ```
66
+ `appearance: false` still hides the whole block; `true`/omitted shows Theme ·
67
+ Palette · Surfaces.
68
+ - **Palette is a dropdown**, not four wrapping buttons. Custom, not a native
69
+ `<select>` (D-4cd764): arrows open and move, Enter/Space choose, Escape closes
70
+ the list without choosing and leaves the menu open; a second Escape closes the
71
+ menu. It opens in flow under its row, so the menu never clips it.
72
+ testids: `user-menu-palette` (the button), `user-menu-palette-list`,
73
+ `user-menu-palette-<id>` (each option). **A Lens flow that clicked
74
+ `user-menu-palette-<id>` directly must click `user-menu-palette` first.**
75
+ - New dependency: `@broberg/ui-controls-core` (its keyboard reducer — pure, no DOM).
76
+ - Pairs with `@broberg/theme` 0.13.0, where Layered is the default. Works with
77
+ ≥ 0.11.0 too; then the old default (Flat) applies.
78
+
56
79
  ## 0.9.2 — ConfirmDialog listens from the first frame (F092.14)
57
80
 
58
81
  0.9.1 carried the same change but never reached npm: its publish run was stopped by a flaky test in this package (happy-dom followed an external link to the live site; now switched off). 0.9.2 is the first published version with the fix.
package/css/app-shell.css CHANGED
@@ -148,6 +148,13 @@
148
148
  .bas-sidebar.is-railed .bas-item__bar { left: -6px; }
149
149
  .bas-sidebar.is-railed .bas-item__count { position: absolute; top: 2px; right: 2px; padding: 0 4px; }
150
150
  .bas-sidebar.is-railed .bas-sidebar__head { justify-content: center; padding: 10px 0 4px; }
151
+ /* F092.16 — the flyout renders INSIDE the railed sidebar, so every rail rule above
152
+ * hit its children too: labels hidden (0,3,0 beat the flyout's 0,2,0), items
153
+ * centred with no padding. An empty flyout with only a title (appkit, Lens
154
+ * 3aca21d1). These restore the flyout's own item layout at 0,4,0. */
155
+ .bas-sidebar.is-railed .bas-flyout .bas-item__label { display: block; }
156
+ .bas-sidebar.is-railed .bas-flyout .bas-item { justify-content: flex-start; padding: 6px 8px; }
157
+ .bas-sidebar.is-railed .bas-flyout .bas-item__count { position: static; padding: 1px 6px; }
151
158
  .bas-sidebar.is-railed .bas-sidebar__mark { display: flex; align-items: center; justify-content: center; }
152
159
 
153
160
  /* Desktop fold: "offcanvas" hides the sidebar, "icon" keeps a 48px rail. */
@@ -291,6 +298,27 @@
291
298
  }
292
299
  .bas-seg > button[aria-pressed="true"] { background: var(--olive-btn); color: var(--paper, var(--bg-card)); }
293
300
  .bas-seg > button:not([aria-pressed="true"]):hover { color: var(--fg); }
301
+ /* F001.23 — the palette dropdown. The list opens IN FLOW under its row (the
302
+ menu clips its overflow, so an absolute list near the bottom would be cut);
303
+ display:contents lets button and list wrap inside .bas-pref. */
304
+ .bas-pref:has(> .bas-dd) { flex-wrap: wrap; }
305
+ .bas-dd { display: contents; }
306
+ .bas-dd__btn {
307
+ display: inline-flex; align-items: center; gap: 8px; min-width: 120px; justify-content: space-between;
308
+ font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; font-weight: 500; padding: 5px 10px;
309
+ border-radius: var(--bas-radius); background: var(--bg-sunk); border: 1px solid var(--border); color: var(--fg); cursor: pointer;
310
+ }
311
+ .bas-dd__btn:hover { border-color: var(--fg-faint); }
312
+ .bas-dd__btn:active { background: var(--active); }
313
+ .bas-dd__btn[aria-expanded="true"] .bas-dd__caret { transform: rotate(180deg); }
314
+ .bas-dd__caret { color: var(--fg-muted); transition: transform .12s; }
315
+ .bas-dd__list {
316
+ flex-basis: 100%; list-style: none; margin: 4px 0 0; padding: 4px;
317
+ border-radius: var(--bas-radius); background: var(--bg-sunk); border: 1px solid var(--border);
318
+ }
319
+ .bas-dd__opt { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: calc(var(--bas-radius) - 2px); font-size: 12.5px; color: var(--fg); cursor: pointer; }
320
+ .bas-dd__opt.is-active { background: var(--hover); }
321
+ .bas-dd__opt:active { background: var(--active); }
294
322
 
295
323
  /* ── page header + tabs ── */
296
324
  .bas-pagehead { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 24px 24px 12px; }
package/dist/preact.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  var hooks = require('preact/hooks');
4
4
  var shell = require('@broberg/notifications/shell');
5
+ var uiControlsCore = require('@broberg/ui-controls-core');
5
6
  var theme = require('@broberg/theme');
6
7
  var jsxRuntime = require('preact/jsx-runtime');
7
8
 
@@ -637,7 +638,7 @@ function Sidebar(p) {
637
638
  "aria-label": "Navigation",
638
639
  children: [
639
640
  p.header || p.brandMark ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-sidebar__head", children: [
640
- p.header ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__brand", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(NavLink, { href: p.homeHref, onNavigate: p.onNavigate, onAfter: close, class: "bas-home", "data-testid": p.brandTestId ?? "sidebar-brand-home", children: p.header }) : p.header }) : null,
641
+ p.header ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__brand", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(NavLink, { href: p.homeHref, onNavigate: p.onNavigate, onAfter: close, class: "bas-home", "data-testid": p.brandTestId ?? "sidebar-brand-home", "aria-label": p.homeLabel, children: p.header }) : p.header }) : null,
641
642
  p.brandMark ? /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-sidebar__mark", "data-testid": "sidebar-brand-mark", children: p.homeHref ? /* @__PURE__ */ jsxRuntime.jsx(
642
643
  NavLink,
643
644
  {
@@ -844,6 +845,63 @@ function Segmented(props) {
844
845
  };
845
846
  return /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-seg", "data-testid": props.testid, role: "group", "aria-label": props.label, onKeyDown, children: props.options.map(([v, label]) => /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "aria-pressed": props.value === v, "data-testid": `${props.testid}-${v}`, onClick: () => props.onChange(v), children: label }, v)) });
846
847
  }
848
+ function Dropdown(props) {
849
+ const [state, setState] = hooks.useState({ open: false, highlighted: -1 });
850
+ const current = Math.max(0, props.options.findIndex(([v]) => v === props.value));
851
+ const choose = (i) => {
852
+ setState({ open: false, highlighted: i });
853
+ props.onChange(props.options[i][0]);
854
+ };
855
+ const onKeyDown = (e) => {
856
+ if (e.key === "Tab") return setState((st) => ({ ...st, open: false }));
857
+ const from = state.open ? state : { open: false, highlighted: current };
858
+ const { state: next, intent } = uiControlsCore.selectKeyReducer(from, e.key, props.options.length);
859
+ if (intent.type === "none" && next === from) return;
860
+ e.preventDefault();
861
+ if (state.open || intent.type !== "close") e.stopPropagation();
862
+ if (intent.type === "select") return choose(intent.index);
863
+ setState(next);
864
+ };
865
+ const optId = (v) => `${props.testid}-opt-${v}`;
866
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-dd", onFocusOut: (e) => !e.currentTarget.contains(e.relatedTarget) && setState((st) => ({ ...st, open: false })), children: [
867
+ /* @__PURE__ */ jsxRuntime.jsxs(
868
+ "button",
869
+ {
870
+ type: "button",
871
+ class: "bas-dd__btn",
872
+ "data-testid": props.testid,
873
+ "aria-haspopup": "listbox",
874
+ "aria-expanded": state.open,
875
+ "aria-label": props.label,
876
+ "aria-activedescendant": state.open && state.highlighted >= 0 ? optId(props.options[state.highlighted][0]) : void 0,
877
+ onClick: () => setState((st) => ({ open: !st.open, highlighted: current })),
878
+ onKeyDown,
879
+ children: [
880
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: props.options[current]?.[1] }),
881
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-dd__caret", "aria-hidden": "true", children: "\u25BE" })
882
+ ]
883
+ }
884
+ ),
885
+ state.open ? /* @__PURE__ */ jsxRuntime.jsx("ul", { class: "bas-dd__list", role: "listbox", "aria-label": props.label, "data-testid": `${props.testid}-list`, children: props.options.map(([v, label], i) => /* @__PURE__ */ jsxRuntime.jsxs(
886
+ "li",
887
+ {
888
+ id: optId(v),
889
+ role: "option",
890
+ "aria-selected": props.value === v,
891
+ class: "bas-dd__opt" + (i === state.highlighted ? " is-active" : ""),
892
+ "data-testid": `${props.testid}-${v}`,
893
+ onMouseDown: (e) => e.preventDefault(),
894
+ onMouseEnter: () => setState((st) => ({ ...st, highlighted: i })),
895
+ onClick: () => choose(i),
896
+ children: [
897
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-mi__check", "aria-hidden": "true", children: props.value === v ? "\u2713" : "" }),
898
+ label
899
+ ]
900
+ },
901
+ v
902
+ )) }) : null
903
+ ] });
904
+ }
847
905
  function UserMenu(p) {
848
906
  const t = TEXT[p.lang];
849
907
  const [open, setOpen] = hooks.useState(false);
@@ -1004,16 +1062,16 @@ function UserMenu(p) {
1004
1062
  ] }),
1005
1063
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1006
1064
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.palette }),
1007
- /* @__PURE__ */ jsxRuntime.jsx(Segmented, { testid: "user-menu-palette", value: palette, options: theme.PALETTES.map((x) => [x, theme.PALETTE_LABELS[x]]), onChange: theme.setPalette })
1065
+ /* @__PURE__ */ jsxRuntime.jsx(Dropdown, { testid: "user-menu-palette", label: t.palette, value: palette, options: theme.PALETTES.map((x) => [x, theme.PALETTE_LABELS[x]]), onChange: theme.setPalette })
1008
1066
  ] }),
1009
1067
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1010
1068
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.surfaces }),
1011
1069
  /* @__PURE__ */ jsxRuntime.jsx(Segmented, { testid: "user-menu-surfaces", value: surfaces, options: [["flat", t.flat], ["layered", t.layered]], onChange: theme.setSurfaces })
1012
1070
  ] }),
1013
- /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1071
+ typeof p.appearance === "object" && p.appearance.backdrop ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1014
1072
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.backdrop }),
1015
1073
  /* @__PURE__ */ jsxRuntime.jsx(Segmented, { testid: "user-menu-backdrop", value: backdrop, options: [["neurons", t.neurons], ["plain", t.plain]], onChange: theme.setBackdrop })
1016
- ] })
1074
+ ] }) : null
1017
1075
  ] })
1018
1076
  ] }) : null,
1019
1077
  p.language ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [