@protobase/ui 0.14.0 → 0.14.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.
Files changed (30) hide show
  1. package/dist/app/displays/list-cards.js +2 -2
  2. package/dist/app/displays/virtual-cards.js +43 -65
  3. package/dist/app/list-page.js +9 -2
  4. package/dist/app-shell/app-shell.js +54 -38
  5. package/dist/app-shell/bottom-bar.d.ts +1 -0
  6. package/dist/app-shell/bottom-bar.js +3 -2
  7. package/dist/app-shell/index.d.ts +1 -0
  8. package/dist/app-shell/page-scroll.d.ts +1 -0
  9. package/dist/app-shell/page-scroll.js +56 -0
  10. package/dist/app-shell/sidebar.js +2 -2
  11. package/dist/app-shell/top-bar.js +3 -2
  12. package/dist/data-grid/data-grid.js +92 -85
  13. package/dist/data-grid/geometry.js +26 -6
  14. package/dist/data-grid/grid-footer.js +5 -12
  15. package/dist/data-grid/scroll-parent.js +35 -0
  16. package/dist/data-grid/use-virtual-rows.js +123 -18
  17. package/dist/package.js +1 -1
  18. package/dist/shell/assets/app-Dkz6z_t_.js +66 -0
  19. package/dist/shell/assets/{index-CwUFCDf9.js → index-BCLK6g9a.js} +1 -1
  20. package/dist/shell/assets/index-BQ4FIU4i.css +2 -0
  21. package/dist/shell/assets/{maplibre-gl-QGc1hPKt.js → maplibre-gl-DWx1yzYO.js} +1 -1
  22. package/dist/shell/assets/{record-map-D5vqYcYw.js → record-map-C2M8dEjy.js} +2 -2
  23. package/dist/shell/assets/{records-map-DqqaK1rm.js → records-map-huS0J9xV.js} +1 -1
  24. package/dist/shell/assets/{shared-protobase-ui-0ozDlcDr.js → shared-protobase-ui-CsJxPCx6.js} +1 -1
  25. package/dist/shell/index.html +4 -4
  26. package/dist/shell/protobase.shell.json +1 -1
  27. package/package.json +4 -4
  28. package/dist/data-grid/use-grid-scroll.js +0 -87
  29. package/dist/shell/assets/app-DtPlcCA3.js +0 -66
  30. package/dist/shell/assets/index-CYWfU_c1.css +0 -2
@@ -10,8 +10,8 @@ const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
10
10
  const pictureHeight = (width) => width * .75;
11
11
  const plainHeight = 44;
12
12
  /**
13
- * The list page's cards display: a card per record (see `RecordCard`), scrolled like the table, with which of the
14
- * matching records are in view below. An embedded list, which shows a few cards whole, draws them with `CardsDisplay`.
13
+ * The list page's cards display: a card per record (see `RecordCard`), scrolled like the table. An embedded list,
14
+ * which shows a few cards whole, draws them with `CardsDisplay`.
15
15
  */
16
16
  const ListCards = ({ model, view, display, list }) => {
17
17
  const plural = view?.names?.plural ?? humanize(model.name);
@@ -3,15 +3,17 @@ import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
3
  import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
4
  import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
5
5
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
6
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { jsx } from "react/jsx-runtime";
7
7
  //#region src/app/displays/virtual-cards.tsx
8
8
  /**
9
- * Cards in rows as wide as the list, scrolled like the table: only the rows in view are drawn, over a scroller that
10
- * holds any number of records, and the records are loaded in the table's pages, so a change of width keeps the loaded
11
- * pages and the first record in view. Every card in a width is as tall as the tallest drawn so far.
9
+ * Cards in rows as wide as the list, scrolled like the table by the page or the nearest scrolling ancestor: the list is
10
+ * as tall as all its rows, only the rows in view are drawn, and the records are loaded in the table's pages, so a
11
+ * change of width keeps the loaded pages and the first record in view. Every card in a width is as tall as the tallest
12
+ * drawn so far.
12
13
  */
13
14
  const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
14
15
  const { total } = source;
16
+ const [startIndex] = useState(initialIndex);
15
17
  const [width, setWidth] = useState(0);
16
18
  const columns = cardColumns(width);
17
19
  const itemWidth = cardWidth(width, columns);
@@ -29,13 +31,15 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
29
31
  layout,
30
32
  top: 0
31
33
  });
32
- const changed = anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight;
34
+ const changed = anchor.current.top > 0 && (anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight);
33
35
  const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
36
+ const ready = itemWidth > 0 && measured.width === itemWidth;
34
37
  const scroll = useVirtualRows({
35
38
  total: Math.ceil(total / columns),
36
39
  rowHeight,
37
- initialIndex: Math.floor(initialIndex / columns),
38
- settle
40
+ initialIndex: Math.floor(startIndex / columns),
41
+ settle,
42
+ ready
39
43
  });
40
44
  useLayoutEffect(() => {
41
45
  if (changed) scroll.scrollTo(scroll.top);
@@ -43,7 +47,7 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
43
47
  layout,
44
48
  top: scroll.top
45
49
  };
46
- if (!changed) onScrollIndex(Math.floor(scroll.top / rowHeight) * columns);
50
+ if (!changed) onScrollIndex(Math.floor(Math.max(0, scroll.top) / rowHeight) * columns);
47
51
  });
48
52
  const range = recordRange(scroll.first, scroll.last, columns, total);
49
53
  const getRow = usePagedRows({
@@ -52,9 +56,8 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
52
56
  loadRows: source.loadRows,
53
57
  latencyMs: 0
54
58
  }, range.start, range.end);
55
- const bodyRef = useRef(null);
56
59
  useLayoutEffect(() => {
57
- const body = bodyRef.current;
60
+ const body = scroll.ref.current;
58
61
  if (!body) return;
59
62
  const observer = new ResizeObserver(() => setWidth(body.clientWidth));
60
63
  observer.observe(body);
@@ -77,61 +80,36 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
77
80
  observer.observe(item);
78
81
  return () => observer.unobserve(item);
79
82
  }, [observer]);
80
- const firstShown = Math.min(total, Math.floor(scroll.top / rowHeight) * columns + 1);
81
- const lastShown = Math.min(total, (Math.floor((scroll.top + Math.max(0, scroll.viewport - 1)) / rowHeight) + 1) * columns);
82
- return /* @__PURE__ */ jsxs("div", {
83
- className: "flex min-h-0 flex-1 flex-col",
84
- children: [/* @__PURE__ */ jsx("div", {
85
- ref: scroll.ref,
86
- onScroll: scroll.onScroll,
87
- className: "relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
88
- children: /* @__PURE__ */ jsx("div", {
89
- style: { height: scroll.geometry.contentHeight || void 0 },
90
- children: /* @__PURE__ */ jsx("div", {
91
- ref: bodyRef,
92
- className: "sticky top-0 overflow-clip",
93
- style: { height: scroll.viewport },
94
- children: /* @__PURE__ */ jsx("ul", {
95
- className: "relative",
96
- "aria-label": label,
97
- children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
98
- const index = rowIndex * columns + column;
99
- if (index >= total) return [];
100
- const row = getRow(index);
101
- const place = {
102
- top: offset,
103
- left: column * (itemWidth + 12),
104
- width: itemWidth,
105
- minHeight: itemHeight
106
- };
107
- if (!row) return /* @__PURE__ */ jsx("li", {
108
- "aria-hidden": true,
109
- className: "absolute grid grid-cols-[minmax(0,1fr)]",
110
- style: place,
111
- children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
112
- }, `skeleton-${index}`);
113
- return /* @__PURE__ */ jsx("li", {
114
- ref: observe,
115
- "aria-posinset": index + 1,
116
- "aria-setsize": total,
117
- className: "absolute grid grid-cols-[minmax(0,1fr)]",
118
- style: place,
119
- children: children(row)
120
- }, row.id);
121
- }))
122
- })
123
- })
124
- })
125
- }), /* @__PURE__ */ jsxs("div", {
126
- className: "py-3 text-xs tabular-nums text-muted-foreground",
127
- children: [
128
- firstShown.toLocaleString("en-IE"),
129
- "–",
130
- lastShown.toLocaleString("en-IE"),
131
- " of ",
132
- total.toLocaleString("en-IE")
133
- ]
134
- })]
83
+ return /* @__PURE__ */ jsx("ul", {
84
+ ref: scroll.ref,
85
+ className: "relative min-w-0 flex-auto",
86
+ "aria-label": label,
87
+ style: { height: scroll.geometry.contentHeight },
88
+ children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
89
+ const index = rowIndex * columns + column;
90
+ if (index >= total) return [];
91
+ const row = getRow(index);
92
+ const place = {
93
+ top: offset,
94
+ left: column * (itemWidth + 12),
95
+ width: itemWidth,
96
+ minHeight: itemHeight
97
+ };
98
+ if (!row) return /* @__PURE__ */ jsx("li", {
99
+ "aria-hidden": true,
100
+ className: "absolute grid grid-cols-[minmax(0,1fr)]",
101
+ style: place,
102
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
103
+ }, `skeleton-${index}`);
104
+ return /* @__PURE__ */ jsx("li", {
105
+ ref: observe,
106
+ "aria-posinset": index + 1,
107
+ "aria-setsize": total,
108
+ className: "absolute grid grid-cols-[minmax(0,1fr)]",
109
+ style: place,
110
+ children: children(row)
111
+ }, row.id);
112
+ }))
135
113
  });
136
114
  };
137
115
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { useIsDesktop } from "../lib/use-media-query.js";
2
+ import { usePageScroll } from "../app-shell/page-scroll.js";
2
3
  import { PageHeader } from "../app-shell/page-header.js";
3
4
  import { useAuth } from "../auth/auth-provider.js";
4
5
  import { Button } from "../primitives/button.js";
@@ -100,6 +101,8 @@ const LoadedList = ({ model, address }) => {
100
101
  orderBy
101
102
  });
102
103
  const display = shownDisplay(view, address.display);
104
+ const pageScroll = !display || display.kind === "cards";
105
+ usePageScroll(pageScroll);
103
106
  const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
104
107
  const scroll = useListScroll();
105
108
  const titleField = recordTitleField(model, view);
@@ -255,7 +258,8 @@ const LoadedList = ({ model, address }) => {
255
258
  }] : [],
256
259
  rowNumbers: false,
257
260
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
258
- className: "min-h-0 flex-1"
261
+ scroll: "parent",
262
+ className: "flex-1"
259
263
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
260
264
  const cardsList = {
261
265
  ...rows,
@@ -314,7 +318,10 @@ const LoadedList = ({ model, address }) => {
314
318
  desktop ? desktopToolbar : phoneFilters,
315
319
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
316
320
  className: "flex min-h-0 flex-1 gap-4",
317
- children: [panel, shown]
321
+ children: [/* @__PURE__ */ jsx("div", {
322
+ className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
323
+ children: panel
324
+ }), shown]
318
325
  }) : shown
319
326
  ]
320
327
  });
@@ -2,9 +2,10 @@ import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
3
  import { BottomBar } from "./bottom-bar.js";
4
4
  import { Drawer } from "./drawer.js";
5
+ import { PageScrollContext, usePageInsets } from "./page-scroll.js";
5
6
  import { Sidebar } from "./sidebar.js";
6
7
  import { TopBar } from "./top-bar.js";
7
- import { useEffect, useState } from "react";
8
+ import { useEffect, useRef, useState } from "react";
8
9
  import { jsx, jsxs } from "react/jsx-runtime";
9
10
  //#region src/app-shell/app-shell.tsx
10
11
  const drawerId = "app-drawer";
@@ -15,8 +16,13 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
15
16
  if (desktop) setDrawerOpen(false);
16
17
  }, [desktop]);
17
18
  const tabs = !desktop && bottomBar && bottomBar.length > 0 ? bottomBar : void 0;
18
- const sidebar = (mode) => /* @__PURE__ */ jsx(Sidebar, {
19
+ const [pageScroll, setPageScroll] = useState(false);
20
+ const topBarRef = useRef(null);
21
+ const bottomBarRef = useRef(null);
22
+ usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
23
+ const sidebar = (mode, sticky = false) => /* @__PURE__ */ jsx(Sidebar, {
19
24
  mode,
25
+ sticky,
20
26
  activeItem,
21
27
  user,
22
28
  workspace,
@@ -30,42 +36,52 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
30
36
  organizations,
31
37
  roles
32
38
  });
33
- return /* @__PURE__ */ jsxs("div", {
34
- className: cn("flex h-full min-h-0 w-full overflow-hidden bg-background", className),
35
- children: [
36
- desktop ? sidebar(sidebarMode) : /* @__PURE__ */ jsx(Drawer, {
37
- id: drawerId,
38
- open: drawerOpen,
39
- onClose: () => setDrawerOpen(false),
40
- children: sidebar("text-large")
41
- }),
42
- /* @__PURE__ */ jsxs("div", {
43
- className: "flex min-w-0 flex-1 flex-col",
44
- children: [
45
- /* @__PURE__ */ jsx(TopBar, {
46
- breadcrumb,
47
- onNavigate,
48
- actions,
49
- onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
50
- menuOpen: drawerOpen,
51
- menuControls: drawerId,
52
- search
53
- }),
54
- /* @__PURE__ */ jsx("main", {
55
- className: "flex min-h-0 flex-1 flex-col",
56
- children
57
- }),
58
- tabs && /* @__PURE__ */ jsx(BottomBar, {
59
- tabs,
60
- onNavigate,
61
- onMore: () => setDrawerOpen(true),
62
- moreOpen: drawerOpen,
63
- moreControls: drawerId
64
- })
65
- ]
66
- }),
67
- rightPanel
68
- ]
39
+ return /* @__PURE__ */ jsx(PageScrollContext.Provider, {
40
+ value: setPageScroll,
41
+ children: /* @__PURE__ */ jsxs("div", {
42
+ className: cn(pageScroll ? "flex min-h-full w-full overflow-x-clip bg-background" : "flex h-full min-h-0 w-full overflow-hidden bg-background", className),
43
+ children: [
44
+ desktop ? sidebar(sidebarMode, pageScroll) : /* @__PURE__ */ jsx(Drawer, {
45
+ id: drawerId,
46
+ open: drawerOpen,
47
+ onClose: () => setDrawerOpen(false),
48
+ children: sidebar("text-large")
49
+ }),
50
+ /* @__PURE__ */ jsxs("div", {
51
+ className: "flex min-w-0 flex-1 flex-col",
52
+ children: [
53
+ /* @__PURE__ */ jsx(TopBar, {
54
+ ref: topBarRef,
55
+ sticky: pageScroll,
56
+ breadcrumb,
57
+ onNavigate,
58
+ actions,
59
+ onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
60
+ menuOpen: drawerOpen,
61
+ menuControls: drawerId,
62
+ search
63
+ }),
64
+ /* @__PURE__ */ jsx("main", {
65
+ className: pageScroll ? "flex flex-1 flex-col" : "flex min-h-0 flex-1 flex-col",
66
+ children
67
+ }),
68
+ tabs && /* @__PURE__ */ jsx(BottomBar, {
69
+ ref: bottomBarRef,
70
+ sticky: pageScroll,
71
+ tabs,
72
+ onNavigate,
73
+ onMore: () => setDrawerOpen(true),
74
+ moreOpen: drawerOpen,
75
+ moreControls: drawerId
76
+ })
77
+ ]
78
+ }),
79
+ rightPanel && /* @__PURE__ */ jsx("div", {
80
+ className: pageScroll ? "sticky top-0 flex h-dvh shrink-0 self-start" : "flex shrink-0",
81
+ children: rightPanel
82
+ })
83
+ ]
84
+ })
69
85
  });
70
86
  };
71
87
  //#endregion
@@ -1 +1,2 @@
1
+ import { Ref } from "react";
1
2
  import "lucide-react";
@@ -9,11 +9,12 @@ const tab = "flex min-h-[49px] min-w-0 flex-1 flex-col items-center justify-cent
9
9
  * last, which opens the navigation drawer and is the current tab on screens the others do not hold. The padding below
10
10
  * keeps the tabs clear of the iPhone home indicator.
11
11
  */
12
- const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls }) => {
12
+ const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls, sticky, ref }) => {
13
13
  const moreCurrent = !tabs.some((item) => item.active);
14
14
  return /* @__PURE__ */ jsxs("nav", {
15
+ ref,
15
16
  "aria-label": "Tabs",
16
- className: "flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden",
17
+ className: cn("flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden", sticky && "sticky bottom-0 z-30"),
17
18
  children: [tabs.map((item) => {
18
19
  const Icon = item.icon;
19
20
  return /* @__PURE__ */ jsxs("a", {
@@ -6,6 +6,7 @@ import "./global-search.js";
6
6
  import "./breadcrumb.js";
7
7
  import "./app-shell.js";
8
8
  import "./page-header.js";
9
+ import "./page-scroll.js";
9
10
  import "./sidebar.js";
10
11
  import "./status-dot.js";
11
12
  export type { SidebarMode };
@@ -0,0 +1 @@
1
+ import "react";
@@ -0,0 +1,56 @@
1
+ import { createContext, useContext, useLayoutEffect } from "react";
2
+ //#region src/app-shell/page-scroll.ts
3
+ /** Turns the shell's page scrolling on or off; pages use `usePageScroll`. */
4
+ const PageScrollContext = createContext(() => {});
5
+ /**
6
+ * While the calling page is shown and `on`, the document scrolls instead of a box of the screen's height: the top and
7
+ * bottom bars and the sidebar stick, and the page grows with its content, so the browser's own scrolling applies
8
+ * throughout, scrolling to the top included, as tapping the status bar on an iPhone does. Sticky content in the page
9
+ * stays clear of the bars with `--pb-sticky-top` and `--pb-sticky-bottom`.
10
+ */
11
+ const usePageScroll = (on) => {
12
+ const set = useContext(PageScrollContext);
13
+ useLayoutEffect(() => {
14
+ set(on);
15
+ return () => set(false);
16
+ }, [set, on]);
17
+ };
18
+ const properties = [
19
+ "--pb-sticky-top",
20
+ "--pb-sticky-bottom",
21
+ "scroll-padding-top",
22
+ "scroll-padding-bottom"
23
+ ];
24
+ /**
25
+ * While the page scrolls, says how tall its sticky bars are, on the root element: as `--pb-sticky-top` and
26
+ * `--pb-sticky-bottom` for content that sticks inside them, and as the page's scroll padding, which the browser keeps
27
+ * focused and scrolled-to content clear of, and which a virtual list reads as the part of the page it shows in.
28
+ */
29
+ const usePageInsets = (on, top, bottom, bottomShown) => {
30
+ useLayoutEffect(() => {
31
+ if (!on) return;
32
+ const root = document.documentElement;
33
+ const update = () => {
34
+ const above = `${top.current?.offsetHeight ?? 0}px`;
35
+ const below = `${bottom.current?.offsetHeight ?? 0}px`;
36
+ root.style.setProperty("--pb-sticky-top", above);
37
+ root.style.setProperty("--pb-sticky-bottom", below);
38
+ root.style.setProperty("scroll-padding-top", above);
39
+ root.style.setProperty("scroll-padding-bottom", below);
40
+ };
41
+ const observer = new ResizeObserver(update);
42
+ for (const bar of [top.current, bottom.current]) if (bar) observer.observe(bar);
43
+ update();
44
+ return () => {
45
+ observer.disconnect();
46
+ for (const property of properties) root.style.removeProperty(property);
47
+ };
48
+ }, [
49
+ on,
50
+ top,
51
+ bottom,
52
+ bottomShown
53
+ ]);
54
+ };
55
+ //#endregion
56
+ export { PageScrollContext, usePageInsets, usePageScroll };
@@ -8,13 +8,13 @@ import { SidebarGroup } from "./sidebar-group.js";
8
8
  import { LogIn } from "lucide-react";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
10
  //#region src/app-shell/sidebar.tsx
11
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles }) => {
11
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles, sticky }) => {
12
12
  const expanding = mode === "icon-expand";
13
13
  const expanded = expanding && forceExpanded;
14
14
  const inline = isTextMode(mode) || expanding;
15
15
  return /* @__PURE__ */ jsx("nav", {
16
16
  "aria-label": "Resources",
17
- className: cn("relative z-30 shrink-0", sidebarWidth[mode]),
17
+ className: cn(sticky ? "sticky top-0 h-dvh self-start" : "relative", "z-30 shrink-0", sidebarWidth[mode]),
18
18
  children: /* @__PURE__ */ jsxs("div", {
19
19
  className: cn("group/side absolute inset-y-0 left-0 flex flex-col overflow-hidden border-r bg-sidebar", expanding ? "w-14 transition-[width,box-shadow] duration-150 hover:w-60 hover:shadow-pop focus-within:w-60 focus-within:shadow-pop" : "w-full", expanded && "w-60 shadow-pop"),
20
20
  children: [
@@ -7,7 +7,7 @@ import { ChevronLeft, Menu, Search, X } from "lucide-react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app-shell/top-bar.tsx
9
9
  const iconButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
10
- const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search }) => {
10
+ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search, sticky, ref }) => {
11
11
  const [searchOpen, setSearchOpen] = useState(false);
12
12
  const back = backTarget(breadcrumb);
13
13
  const desktopSearch = useRef(null);
@@ -24,7 +24,8 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
24
24
  return () => window.removeEventListener("keydown", onKey);
25
25
  }, [search]);
26
26
  return /* @__PURE__ */ jsxs("header", {
27
- className: "relative flex h-12 shrink-0 items-center gap-1 border-b bg-background px-2 md:gap-4 md:px-5",
27
+ ref,
28
+ className: cn(sticky ? "sticky top-0 z-30" : "relative", "flex h-12 shrink-0 items-center gap-1 border-b bg-background px-2 md:gap-4 md:px-5"),
28
29
  children: [
29
30
  onMenu && /* @__PURE__ */ jsx("button", {
30
31
  type: "button",