@broberg/app-shell 0.10.1 → 0.11.1

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,46 @@ 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.1 — the palette list floats over the menu (F001.25)
57
+
58
+ Christian 10/10: «Kunne denne ikke være en pop-over ting så dialogen ikke bliver
59
+ forstørret hver gang man klikker på listen?» The list is now a popover
60
+ (`position: fixed`, placed from the button): the menu keeps its height and
61
+ nothing below moves. It opens below the button, upward when there is no room,
62
+ stays inside the viewport, and closes when the menu scrolls or the window
63
+ resizes. testids and keys unchanged. Rendering moved: the open list no longer
64
+ pushes «Flader» and the rest down.
65
+
66
+ ## 0.11.0 — the user menu's Appearance, tidied (F001.23)
67
+
68
+ Christian 10/10, via appkit: «Neuroner … er reelt set kun til trail og cardmem» and «Paletten er ikke pæn».
69
+
70
+ - **Backdrop (Neurons · Plain) is opt-in.** It only means something in an app
71
+ that draws the neuron canvas (`mountConstellation` from `@broberg/theme`).
72
+ **trail and cardmem: add one line to keep it:**
73
+ ```tsx
74
+ <AppShell userMenu={{ appearance: { backdrop: true } }} … />
75
+ ```
76
+ `appearance: false` still hides the whole block; `true`/omitted shows Theme ·
77
+ Palette · Surfaces.
78
+ - **Palette is a dropdown**, not four wrapping buttons. Custom, not a native
79
+ `<select>` (D-4cd764): arrows open and move, Enter/Space choose, Escape closes
80
+ the list without choosing and leaves the menu open; a second Escape closes the
81
+ menu. (0.11.1: it floats over the menu as a popover.)
82
+ testids: `user-menu-palette` (the button), `user-menu-palette-list`,
83
+ `user-menu-palette-<id>` (each option). **A Lens flow that clicked
84
+ `user-menu-palette-<id>` directly must click `user-menu-palette` first.**
85
+ - New dependency: `@broberg/ui-controls-core` (its keyboard reducer — pure, no DOM).
86
+ - Pairs with `@broberg/theme` 0.13.0, where Layered is the default. Works with
87
+ ≥ 0.11.0 too; then the old default (Flat) applies.
88
+
56
89
  ## 0.9.2 — ConfirmDialog listens from the first frame (F092.14)
57
90
 
58
91
  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
@@ -298,6 +298,27 @@
298
298
  }
299
299
  .bas-seg > button[aria-pressed="true"] { background: var(--olive-btn); color: var(--paper, var(--bg-card)); }
300
300
  .bas-seg > button:not([aria-pressed="true"]):hover { color: var(--fg); }
301
+ /* F001.23/F001.25 — the palette dropdown. The list is a POPOVER (position
302
+ fixed, placed from the button in JS), so opening it neither grows the menu
303
+ nor gets clipped by its overflow. */
304
+ .bas-dd { position: relative; display: inline-flex; }
305
+ .bas-dd__btn {
306
+ display: inline-flex; align-items: center; gap: 8px; min-width: 120px; justify-content: space-between;
307
+ font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; font-weight: 500; padding: 5px 10px;
308
+ border-radius: var(--bas-radius); background: var(--bg-sunk); border: 1px solid var(--border); color: var(--fg); cursor: pointer;
309
+ }
310
+ .bas-dd__btn:hover { border-color: var(--fg-faint); }
311
+ .bas-dd__btn:active { background: var(--active); }
312
+ .bas-dd__btn[aria-expanded="true"] .bas-dd__caret { transform: rotate(180deg); }
313
+ .bas-dd__caret { color: var(--fg-muted); transition: transform .12s; }
314
+ .bas-dd__list {
315
+ z-index: 70; list-style: none; margin: 0; padding: 4px; max-height: min(60vh, 320px); overflow-y: auto;
316
+ border-radius: var(--bas-radius); background: var(--bg-card); border: 1px solid var(--border);
317
+ box-shadow: 0 12px 32px rgba(0, 0, 0, .22);
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); }
301
322
 
302
323
  /* ── page header + tabs ── */
303
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
 
@@ -844,6 +845,91 @@ 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 btnRef = hooks.useRef(null);
851
+ const listRef = hooks.useRef(null);
852
+ const [pos, setPos] = hooks.useState(void 0);
853
+ hooks.useLayoutEffect(() => {
854
+ if (!state.open || !btnRef.current) return setPos(void 0);
855
+ const r = btnRef.current.getBoundingClientRect();
856
+ const vw = window.innerWidth, vh = window.innerHeight, m = 8;
857
+ const h = listRef.current?.offsetHeight ?? 0;
858
+ const width = Math.min(Math.max(r.width, 160), vw - 2 * m);
859
+ const left = Math.max(m, Math.min(r.right - width, vw - width - m));
860
+ const below = r.bottom + 4;
861
+ const top = below + h <= vh - m || r.top - 4 - h < m ? Math.min(below, vh - m - h) : r.top - 4 - h;
862
+ setPos({ position: "fixed", left: `${left}px`, top: `${Math.max(m, top)}px`, width: `${width}px` });
863
+ }, [state.open]);
864
+ hooks.useEffect(() => {
865
+ if (!state.open) return;
866
+ const close = (e) => {
867
+ if (e.target instanceof Node && listRef.current?.contains(e.target)) return;
868
+ setState((st) => ({ ...st, open: false }));
869
+ };
870
+ window.addEventListener("scroll", close, true);
871
+ window.addEventListener("resize", close);
872
+ return () => {
873
+ window.removeEventListener("scroll", close, true);
874
+ window.removeEventListener("resize", close);
875
+ };
876
+ }, [state.open]);
877
+ const current = Math.max(0, props.options.findIndex(([v]) => v === props.value));
878
+ const choose = (i) => {
879
+ setState({ open: false, highlighted: i });
880
+ props.onChange(props.options[i][0]);
881
+ };
882
+ const onKeyDown = (e) => {
883
+ if (e.key === "Tab") return setState((st) => ({ ...st, open: false }));
884
+ const from = state.open ? state : { open: false, highlighted: current };
885
+ const { state: next, intent } = uiControlsCore.selectKeyReducer(from, e.key, props.options.length);
886
+ if (intent.type === "none" && next === from) return;
887
+ e.preventDefault();
888
+ if (state.open || intent.type !== "close") e.stopPropagation();
889
+ if (intent.type === "select") return choose(intent.index);
890
+ setState(next);
891
+ };
892
+ const optId = (v) => `${props.testid}-opt-${v}`;
893
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-dd", onFocusOut: (e) => !e.currentTarget.contains(e.relatedTarget) && setState((st) => ({ ...st, open: false })), children: [
894
+ /* @__PURE__ */ jsxRuntime.jsxs(
895
+ "button",
896
+ {
897
+ ref: btnRef,
898
+ type: "button",
899
+ class: "bas-dd__btn",
900
+ "data-testid": props.testid,
901
+ "aria-haspopup": "listbox",
902
+ "aria-expanded": state.open,
903
+ "aria-label": props.label,
904
+ "aria-activedescendant": state.open && state.highlighted >= 0 ? optId(props.options[state.highlighted][0]) : void 0,
905
+ onClick: () => setState((st) => ({ open: !st.open, highlighted: current })),
906
+ onKeyDown,
907
+ children: [
908
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: props.options[current]?.[1] }),
909
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-dd__caret", "aria-hidden": "true", children: "\u25BE" })
910
+ ]
911
+ }
912
+ ),
913
+ state.open ? /* @__PURE__ */ jsxRuntime.jsx("ul", { ref: listRef, class: "bas-dd__list", style: pos ?? { position: "fixed", visibility: "hidden" }, role: "listbox", "aria-label": props.label, "data-testid": `${props.testid}-list`, children: props.options.map(([v, label], i) => /* @__PURE__ */ jsxRuntime.jsxs(
914
+ "li",
915
+ {
916
+ id: optId(v),
917
+ role: "option",
918
+ "aria-selected": props.value === v,
919
+ class: "bas-dd__opt" + (i === state.highlighted ? " is-active" : ""),
920
+ "data-testid": `${props.testid}-${v}`,
921
+ onMouseDown: (e) => e.preventDefault(),
922
+ onMouseEnter: () => setState((st) => ({ ...st, highlighted: i })),
923
+ onClick: () => choose(i),
924
+ children: [
925
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-mi__check", "aria-hidden": "true", children: props.value === v ? "\u2713" : "" }),
926
+ label
927
+ ]
928
+ },
929
+ v
930
+ )) }) : null
931
+ ] });
932
+ }
847
933
  function UserMenu(p) {
848
934
  const t = TEXT[p.lang];
849
935
  const [open, setOpen] = hooks.useState(false);
@@ -1004,16 +1090,16 @@ function UserMenu(p) {
1004
1090
  ] }),
1005
1091
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1006
1092
  /* @__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 })
1093
+ /* @__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
1094
  ] }),
1009
1095
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1010
1096
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.surfaces }),
1011
1097
  /* @__PURE__ */ jsxRuntime.jsx(Segmented, { testid: "user-menu-surfaces", value: surfaces, options: [["flat", t.flat], ["layered", t.layered]], onChange: theme.setSurfaces })
1012
1098
  ] }),
1013
- /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1099
+ typeof p.appearance === "object" && p.appearance.backdrop ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-pref", children: [
1014
1100
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.backdrop }),
1015
1101
  /* @__PURE__ */ jsxRuntime.jsx(Segmented, { testid: "user-menu-backdrop", value: backdrop, options: [["neurons", t.neurons], ["plain", t.plain]], onChange: theme.setBackdrop })
1016
- ] })
1102
+ ] }) : null
1017
1103
  ] })
1018
1104
  ] }) : null,
1019
1105
  p.language ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [