@broberg/app-shell 0.19.0 → 0.20.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
@@ -160,6 +160,22 @@ with a building icon (`user-menu-org`); the sidebar foot keeps `subtitle`.
160
160
  at the right of the heading row (`sidebar-group-<slug>-action`), also with `collapsible: false`; clicking it
161
161
  never folds the group. The app opens its own menu from the anchor.
162
162
 
163
+ **0.20.0** — `ItemActionsMenu` (F092.29): the sidebar row menu on its own, so a title «⌄» opens THE SAME
164
+ menu (same keys, confirm, input, submenu). The sidebar's «…» uses this component — one implementation.
165
+
166
+ ```tsx
167
+ const anchor = useRef<HTMLButtonElement>(null);
168
+ const [open, setOpen] = useState(false);
169
+ <button ref={anchor} data-testid="title-trigger" onClick={() => setOpen(!open)}>{title} ⌄</button>
170
+ {open && anchor.current ? (
171
+ <ItemActionsMenu actions={[...rowActions, { id: "share", label: "Del chat" }]} anchor={anchor.current}
172
+ lang="da" label={title} placement="down" testidPrefix="title"
173
+ onAction={(id, value) => …} onClose={() => setOpen(false)} />
174
+ ) : null}
175
+ ```
176
+ testids: `<prefix>-menu`, `<prefix>-action-<id>`, `<prefix>-back`, `<prefix>-input`, `<prefix>-save` — so the
177
+ anchor's own testid must not be `<prefix>-menu`.
178
+
163
179
  ## 0.11.1 — the palette list floats over the menu (F001.25)
164
180
 
165
181
  Christian 10/10: «Kunne denne ikke være en pop-over ting så dialogen ikke bliver
package/css/app-shell.css CHANGED
@@ -70,8 +70,11 @@
70
70
  .bas-sidebar {
71
71
  /* sticky makes its own stacking context, so the rail's flyout can only rise
72
72
  * as high as the sidebar does; without this <main> paints over it and takes
73
- * its clicks (appkit, Lens 839394b1). Below the top bar (50). */
74
- z-index: 20;
73
+ * its clicks (appkit, Lens 839394b1). Below the top bar (50).
74
+ * F092.30 — and ABOVE the inset content header (30): every popover that opens
75
+ * from the sidebar (user menu, row menu, flyout) lives in this context, so at
76
+ * 20 the header's line and blur ran over the user menu (scout #2779). */
77
+ z-index: 40;
75
78
  flex: 0 0 240px; width: 240px;
76
79
  position: sticky; top: var(--bas-top); align-self: flex-start;
77
80
  height: calc(100dvh - var(--bas-top));
package/dist/preact.cjs CHANGED
@@ -516,12 +516,17 @@ var IconBuilding = () => svg(/* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment
516
516
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M9 22v-4h6v4" }),
517
517
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01" })
518
518
  ] }), 15);
519
- function RowActions(props) {
519
+ function ItemActionsMenu(props) {
520
520
  const t = TEXT[props.lang];
521
- const id = props.item.id;
522
- const btn = hooks.useRef(null);
521
+ const pre = props.testidPrefix ?? "item-actions";
523
522
  const menu = hooks.useRef(null);
524
- const [stack, setStack] = hooks.useState([]);
523
+ const first = [{ kind: "list", actions: props.actions }];
524
+ const [stack, setStack] = hooks.useState(first);
525
+ const stackRef = hooks.useRef(first);
526
+ const setStackNow = (next) => {
527
+ stackRef.current = next;
528
+ setStack(next);
529
+ };
525
530
  const [hi, setHiState] = hooks.useState(0);
526
531
  const hiRef = hooks.useRef(0);
527
532
  const setHi = (n) => {
@@ -540,16 +545,12 @@ function RowActions(props) {
540
545
  const [value, setValue] = hooks.useState("");
541
546
  const [pos, setPos] = hooks.useState(void 0);
542
547
  const view = stack[stack.length - 1];
543
- const open = !!view;
544
- const stackRef = hooks.useRef([]);
545
- const setStackNow = (next) => {
546
- stackRef.current = next;
547
- setStack(next);
548
- };
548
+ const closed = hooks.useRef(false);
549
549
  const close = (refocus = true) => {
550
- setStackNow([]);
551
- setArmed(null);
552
- if (refocus) btn.current?.focus();
550
+ if (closed.current) return;
551
+ closed.current = true;
552
+ if (refocus) props.anchor.focus();
553
+ props.onClose();
553
554
  };
554
555
  const show = (v) => {
555
556
  setStackNow([...stackRef.current, v]);
@@ -574,25 +575,24 @@ function RowActions(props) {
574
575
  props.onAction(a.id, value);
575
576
  };
576
577
  hooks.useLayoutEffect(() => {
577
- if (!open || !btn.current) return setPos(void 0);
578
- const r = btn.current.getBoundingClientRect();
578
+ const r = props.anchor.getBoundingClientRect();
579
579
  const vw = window.innerWidth, vh = window.innerHeight, m = 8;
580
580
  const w = Math.min(240, vw - 2 * m);
581
581
  const h = menu.current?.offsetHeight ?? 0;
582
582
  const left = Math.max(m, Math.min(r.right - w, vw - w - m));
583
- const top = r.bottom + 4 + h <= vh - m || r.top - 4 - h < m ? Math.min(r.bottom + 4, vh - m - h) : r.top - 4 - h;
583
+ const fitsBelow = r.bottom + 4 + h <= vh - m;
584
+ const top = props.placement === "down" || fitsBelow || r.top - 4 - h < m ? Math.min(r.bottom + 4, vh - m - h) : r.top - 4 - h;
584
585
  setPos({ position: "fixed", left: `${left}px`, top: `${Math.max(m, top)}px`, width: `${w}px` });
585
- }, [open, stack.length]);
586
+ }, [stack.length]);
586
587
  hooks.useLayoutEffect(() => {
587
- if (!open || !pos) return;
588
- if (view?.kind === "input") menu.current?.querySelector("input")?.focus();
588
+ if (!pos) return;
589
+ if (view.kind === "input") menu.current?.querySelector("input")?.focus();
589
590
  else items.current[hiRef.current]?.focus();
590
- }, [open, pos, hi, stack.length]);
591
+ }, [pos, hi, stack.length]);
591
592
  hooks.useLayoutEffect(() => {
592
- if (!open) return;
593
593
  const outside = (e) => {
594
594
  const n = e.target instanceof Node ? e.target : null;
595
- if (n && (menu.current?.contains(n) || btn.current?.contains(n))) return;
595
+ if (n && (menu.current?.contains(n) || props.anchor.contains(n))) return;
596
596
  close(false);
597
597
  };
598
598
  const early = (e) => {
@@ -611,8 +611,9 @@ function RowActions(props) {
611
611
  window.removeEventListener("scroll", outside, true);
612
612
  window.removeEventListener("resize", outside);
613
613
  };
614
- }, [open]);
614
+ }, []);
615
615
  const onKeyDown = (e) => {
616
+ if (closed.current) return;
616
617
  const stack2 = stackRef.current;
617
618
  const view2 = stack2[stack2.length - 1];
618
619
  if (!view2) return;
@@ -655,6 +656,82 @@ function RowActions(props) {
655
656
  }
656
657
  };
657
658
  onKeyDownRef.current = onKeyDown;
659
+ return /* @__PURE__ */ jsxRuntime.jsxs(
660
+ "div",
661
+ {
662
+ ref: menu,
663
+ class: "bas-rowmenu",
664
+ role: "menu",
665
+ tabIndex: -1,
666
+ "aria-label": view.kind === "list" && view.title ? view.title : props.label,
667
+ "data-testid": `${pre}-menu`,
668
+ style: pos ?? { position: "fixed", visibility: "hidden" },
669
+ onKeyDown,
670
+ children: [
671
+ stack.length > 1 ? /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", class: "bas-rowmenu__back", "data-testid": `${pre}-back`, "aria-label": t.back, onClick: back, children: [
672
+ /* @__PURE__ */ jsxRuntime.jsx(IconChevronLeft, {}),
673
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: view.kind === "list" ? view.title : view.action.label })
674
+ ] }) : null,
675
+ view.kind === "list" ? view.actions.map((a, i) => /* @__PURE__ */ jsxRuntime.jsxs(
676
+ "button",
677
+ {
678
+ type: "button",
679
+ role: "menuitem",
680
+ ref: (el) => {
681
+ items.current[i] = el;
682
+ },
683
+ tabIndex: i === hi ? 0 : -1,
684
+ class: "bas-rowmenu__item" + (i === hi ? " is-active" : "") + (a.danger ? " is-danger" : "") + (armed === a.id ? " is-armed" : ""),
685
+ "data-testid": `${pre}-action-${a.id}`,
686
+ onMouseEnter: () => setHi(i),
687
+ onFocus: () => setHi(i),
688
+ onClick: () => pick(a),
689
+ children: [
690
+ a.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-rowmenu__icon", children: a.icon }) : null,
691
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-rowmenu__label", children: armed === a.id && a.confirm ? a.confirm.label : a.label }),
692
+ a.submenu?.length ? /* @__PURE__ */ jsxRuntime.jsx(IconChevronRight, {}) : a.kbd ? /* @__PURE__ */ jsxRuntime.jsx("kbd", { class: "bas-rowmenu__kbd", children: a.kbd.toUpperCase() }) : null
693
+ ]
694
+ },
695
+ a.id
696
+ )) : /* @__PURE__ */ jsxRuntime.jsxs(
697
+ "form",
698
+ {
699
+ class: "bas-rowmenu__form",
700
+ "data-testid": `${pre}-form`,
701
+ onSubmit: (e) => {
702
+ e.preventDefault();
703
+ save(view.action);
704
+ },
705
+ children: [
706
+ /* @__PURE__ */ jsxRuntime.jsx(
707
+ "input",
708
+ {
709
+ class: "bas-rowmenu__input",
710
+ "data-testid": `${pre}-input`,
711
+ "aria-label": view.action.label,
712
+ value,
713
+ placeholder: view.action.input.placeholder,
714
+ onInput: (e) => setValue(e.currentTarget.value)
715
+ }
716
+ ),
717
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "submit", class: "bas-rowmenu__save", "data-testid": `${pre}-save`, children: view.action.input.saveLabel ?? t.save })
718
+ ]
719
+ }
720
+ )
721
+ ]
722
+ }
723
+ );
724
+ }
725
+ function RowActions(props) {
726
+ const t = TEXT[props.lang];
727
+ const id = props.item.id;
728
+ const btn = hooks.useRef(null);
729
+ const [open, setOpenState] = hooks.useState(false);
730
+ const openRef = hooks.useRef(false);
731
+ const setOpen = (v) => {
732
+ openRef.current = v;
733
+ setOpenState(v);
734
+ };
658
735
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
659
736
  /* @__PURE__ */ jsxRuntime.jsx(
660
737
  "button",
@@ -666,73 +743,24 @@ function RowActions(props) {
666
743
  "aria-label": `${t.moreFor} ${props.item.label}`,
667
744
  "aria-haspopup": "menu",
668
745
  "aria-expanded": open,
669
- onClick: () => stackRef.current.length ? close() : show({ kind: "list", actions: props.item.actions }),
746
+ onClick: () => {
747
+ if (!openRef.current) return setOpen(true);
748
+ setOpen(false);
749
+ btn.current?.focus();
750
+ },
670
751
  children: /* @__PURE__ */ jsxRuntime.jsx(IconMore, {})
671
752
  }
672
753
  ),
673
- view ? /* @__PURE__ */ jsxRuntime.jsxs(
674
- "div",
754
+ open && btn.current ? /* @__PURE__ */ jsxRuntime.jsx(
755
+ ItemActionsMenu,
675
756
  {
676
- ref: menu,
677
- class: "bas-rowmenu",
678
- role: "menu",
679
- tabIndex: -1,
680
- "aria-label": view.kind === "list" && view.title ? view.title : props.item.label,
681
- "data-testid": `sidebar-item-${id}-menu`,
682
- style: pos ?? { position: "fixed", visibility: "hidden" },
683
- onKeyDown,
684
- children: [
685
- stack.length > 1 ? /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", class: "bas-rowmenu__back", "data-testid": `sidebar-item-${id}-back`, "aria-label": t.back, onClick: back, children: [
686
- /* @__PURE__ */ jsxRuntime.jsx(IconChevronLeft, {}),
687
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: view.kind === "list" ? view.title : view.action.label })
688
- ] }) : null,
689
- view.kind === "list" ? view.actions.map((a, i) => /* @__PURE__ */ jsxRuntime.jsxs(
690
- "button",
691
- {
692
- type: "button",
693
- role: "menuitem",
694
- ref: (el) => {
695
- items.current[i] = el;
696
- },
697
- tabIndex: i === hi ? 0 : -1,
698
- class: "bas-rowmenu__item" + (i === hi ? " is-active" : "") + (a.danger ? " is-danger" : "") + (armed === a.id ? " is-armed" : ""),
699
- "data-testid": `sidebar-item-${id}-action-${a.id}`,
700
- onMouseEnter: () => setHi(i),
701
- onFocus: () => setHi(i),
702
- onClick: () => pick(a),
703
- children: [
704
- a.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-rowmenu__icon", children: a.icon }) : null,
705
- /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-rowmenu__label", children: armed === a.id && a.confirm ? a.confirm.label : a.label }),
706
- a.submenu?.length ? /* @__PURE__ */ jsxRuntime.jsx(IconChevronRight, {}) : a.kbd ? /* @__PURE__ */ jsxRuntime.jsx("kbd", { class: "bas-rowmenu__kbd", children: a.kbd.toUpperCase() }) : null
707
- ]
708
- },
709
- a.id
710
- )) : /* @__PURE__ */ jsxRuntime.jsxs(
711
- "form",
712
- {
713
- class: "bas-rowmenu__form",
714
- "data-testid": `sidebar-item-${id}-form`,
715
- onSubmit: (e) => {
716
- e.preventDefault();
717
- save(view.action);
718
- },
719
- children: [
720
- /* @__PURE__ */ jsxRuntime.jsx(
721
- "input",
722
- {
723
- class: "bas-rowmenu__input",
724
- "data-testid": `sidebar-item-${id}-input`,
725
- "aria-label": view.action.label,
726
- value,
727
- placeholder: view.action.input.placeholder,
728
- onInput: (e) => setValue(e.currentTarget.value)
729
- }
730
- ),
731
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "submit", class: "bas-rowmenu__save", "data-testid": `sidebar-item-${id}-save`, children: view.action.input.saveLabel ?? t.save })
732
- ]
733
- }
734
- )
735
- ]
757
+ actions: props.item.actions,
758
+ anchor: btn.current,
759
+ lang: props.lang,
760
+ label: props.item.label,
761
+ testidPrefix: `sidebar-item-${id}`,
762
+ onAction: props.onAction,
763
+ onClose: () => setOpen(false)
736
764
  }
737
765
  ) : null
738
766
  ] });
@@ -2037,6 +2065,7 @@ exports.AnalyticsPage = AnalyticsPage;
2037
2065
  exports.AppShell = AppShell;
2038
2066
  exports.Avatar = Avatar;
2039
2067
  exports.ConfirmDialog = ConfirmDialog;
2068
+ exports.ItemActionsMenu = ItemActionsMenu;
2040
2069
  exports.NotificationBell = NotificationBell;
2041
2070
  exports.PageHeader = PageHeader;
2042
2071
  exports.PageTabs = PageTabs;