@protobase/ui 0.21.8 → 0.21.9

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 (62) hide show
  1. package/dist/app/app.js +1 -0
  2. package/dist/app/charts/chart-view.js +3 -1
  3. package/dist/app/displays/calendar-controls.js +2 -1
  4. package/dist/app/displays/calendar-display.js +2 -1
  5. package/dist/app/displays/calendar-event.js +2 -1
  6. package/dist/app/displays/calendar-range.js +18 -14
  7. package/dist/app/export/cells.js +6 -4
  8. package/dist/app/field-editor.js +13 -1
  9. package/dist/app/import/convert.js +2 -12
  10. package/dist/app/import/locale-formats.js +2 -1
  11. package/dist/app/incoming-card.js +35 -0
  12. package/dist/app/incoming-relations.js +36 -0
  13. package/dist/app/list-chart.js +3 -1
  14. package/dist/app/pages/field-value.js +9 -1
  15. package/dist/app/pages/record-actions.js +34 -16
  16. package/dist/app/record-page.js +26 -18
  17. package/dist/app/runtime/runtime-status.js +5 -4
  18. package/dist/app/search/navigation-hits.js +45 -0
  19. package/dist/app/search/use-global-search.js +13 -4
  20. package/dist/app/use-incoming-counts.js +38 -0
  21. package/dist/app/use-related-records.js +3 -10
  22. package/dist/app-shell/app-shell.js +9 -2
  23. package/dist/app-shell/index.d.ts +1 -0
  24. package/dist/app-shell/logo.d.ts +5 -1
  25. package/dist/app-shell/logo.js +10 -3
  26. package/dist/app-shell/sidebar.js +3 -2
  27. package/dist/app-shell/top-bar-slot.js +11 -0
  28. package/dist/app-shell/top-bar.d.ts +3 -0
  29. package/dist/app-shell/top-bar.js +27 -19
  30. package/dist/data-grid/cells.js +8 -1
  31. package/dist/data-grid/grid-header.js +17 -16
  32. package/dist/data-grid/header-menu.js +16 -6
  33. package/dist/data-grid/row-menu.js +2 -39
  34. package/dist/format/date.js +29 -9
  35. package/dist/format/number-text.js +16 -0
  36. package/dist/format/zone.js +62 -0
  37. package/dist/live/columns-from-model.js +2 -0
  38. package/dist/live/contact-link.js +25 -0
  39. package/dist/live/contact-links.js +42 -0
  40. package/dist/live/display-value.js +14 -4
  41. package/dist/live/field-values.js +20 -9
  42. package/dist/live/filter-string.js +30 -30
  43. package/dist/package.js +1 -1
  44. package/dist/primitives/popover-menu.js +56 -0
  45. package/dist/record-view/action-menu.js +59 -0
  46. package/dist/record-view/record-header.js +2 -6
  47. package/dist/record-view/record-sidebar.js +2 -30
  48. package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
  49. package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
  50. package/dist/shell/assets/{index-xN6_oZU2.js → index-CRNWPM01.js} +1 -1
  51. package/dist/shell/assets/index-CzqIlZPP.css +2 -0
  52. package/dist/shell/assets/{maplibre-Ba9RapjY.js → maplibre-DZlOrjmh.js} +1 -1
  53. package/dist/shell/assets/{markdown-content-BUxrbovf.js → markdown-content-DT_18T89.js} +1 -1
  54. package/dist/shell/assets/{record-map-14zotZ2s.js → record-map-Bmns3gJf.js} +1 -1
  55. package/dist/shell/assets/{records-map-wi-0pXqF.js → records-map-Ci3qFCDs.js} +1 -1
  56. package/dist/shell/assets/{shared-protobase-ui-BVwhKZk9.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
  57. package/dist/shell/index.html +4 -4
  58. package/dist/shell/protobase.shell.json +1 -1
  59. package/package.json +4 -4
  60. package/dist/shell/assets/app-C01lazBK.js +0 -69
  61. package/dist/shell/assets/export-worker-BnOpS_4Q.js +0 -2
  62. package/dist/shell/assets/index-CBGigFxb.css +0 -2
@@ -1,3 +1,4 @@
1
+ import { viewerZone } from "../../format/zone.js";
1
2
  import { formatAgo, formatDateTime } from "../../format/date.js";
2
3
  import { isApiError } from "@protobase/client";
3
4
  //#region src/app/runtime/runtime-status.ts
@@ -23,14 +24,14 @@ const runningText = (status, now) => {
23
24
  return `${label}, started ${formatAgo(seconds)}`;
24
25
  };
25
26
  /** How far the running update is, or when the latest version will be applied on its own, in words; `now` in ms. */
26
- const nextUpdateText = (status, now = Date.now()) => {
27
+ const nextUpdateText = (status, now = Date.now(), zone = viewerZone()) => {
27
28
  if (status.updating) return runningText(status, now);
28
- if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt))} UTC`;
29
+ if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt), zone)}`;
29
30
  return policyLabels[status.policy];
30
31
  };
31
32
  /** How the last update ended, in words, with the endpoint's message for the owner when it has one. */
32
- const lastUpdateText = (last) => {
33
- const when = `${formatDateTime(Date.parse(last.finishedAt))} UTC`;
33
+ const lastUpdateText = (last, zone = viewerZone()) => {
34
+ const when = formatDateTime(Date.parse(last.finishedAt), zone);
34
35
  const headline = {
35
36
  succeeded: `Updated to ${last.toVersion} on ${when}`,
36
37
  unchanged: `The update to ${last.toVersion} on ${when} changed nothing`,
@@ -0,0 +1,45 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { allowEverything } from "../../data/meta-data.js";
3
+ import { createOffered } from "../create/create-offered.js";
4
+ import { navFromMeta } from "../nav-from-meta.js";
5
+ import { appPath } from "@protobase/schema";
6
+ const matches = (label, text) => label.toLowerCase().includes(text.trim().toLowerCase());
7
+ /**
8
+ * The search's own results, before the records: the sidebar's pages and lists whose names hold the text ("stock" finds
9
+ * Stock levels), and "New ..." for each resource the user may create whose name does ("new order" or "order" finds New
10
+ * order). Each is a link, as the sidebar's entries are.
11
+ */
12
+ const navigationHits = (meta, text, basePath = "") => {
13
+ const pages = navFromMeta(meta, basePath).flatMap((group) => group.items.filter((item) => item.href !== void 0 && !item.problem)).filter((item) => matches(item.label, text)).slice(0, 5);
14
+ const asked = text.trim().replace(/^new\s+/i, "");
15
+ const creates = Object.values(meta.resources).flatMap((model) => {
16
+ const view = meta.views[model.name];
17
+ if (!view || view.nav?.hidden || !createOffered(view, meta.pages, meta.permissions[model.name] ?? allowEverything)) return [];
18
+ const singular = view.names?.singular ?? humanize(model.name);
19
+ const label = `New ${singular.toLowerCase()}`;
20
+ if (!matches(label, text) && !matches(singular, asked) && !matches(view.names?.plural ?? humanize(model.name), asked)) return [];
21
+ return [{
22
+ id: `new:${model.name}`,
23
+ title: label,
24
+ href: appPath({
25
+ kind: "create",
26
+ resource: model.name
27
+ }, basePath)
28
+ }];
29
+ });
30
+ return [...pages.length > 0 ? [{
31
+ id: "go-to",
32
+ label: "Go to",
33
+ hits: pages.map((item) => ({
34
+ id: `go-to:${item.id}`,
35
+ title: item.label,
36
+ href: item.href
37
+ }))
38
+ }] : [], ...creates.length > 0 ? [{
39
+ id: "create",
40
+ label: "Create",
41
+ hits: creates.slice(0, 5)
42
+ }] : []];
43
+ };
44
+ //#endregion
45
+ export { navigationHits };
@@ -2,12 +2,14 @@ import { useClient } from "../../data/api-provider.js";
2
2
  import { useRouter } from "../router.js";
3
3
  import { useAdminMeta } from "../meta-gate.js";
4
4
  import { useDebounced } from "../../lib/use-debounced.js";
5
+ import { navigationHits } from "./navigation-hits.js";
5
6
  import { searchFields, searchFilter, searchPlaceholder, searchTargets, toHit } from "./search-model.js";
6
7
  import { useMemo, useState } from "react";
7
8
  import { useQueries } from "@tanstack/react-query";
8
9
  //#region src/app/search/use-global-search.ts
9
10
  /**
10
- * The top bar's search: the typed text, searched in every resource the user can read that has search fields, through
11
+ * The top bar's search: the typed text, matched against the sidebar's pages and lists and the records the user may
12
+ * create ("New order"), then searched in every resource the user can read that has search fields, through
11
13
  * the list API with the user's token, so row filters and hidden fields apply exactly as on the lists. The results stay
12
14
  * while the text does, so opening the box again asks nothing; other texts' results are dropped, so changed text asks again.
13
15
  */
@@ -36,7 +38,13 @@ const useGlobalSearch = () => {
36
38
  fields: searchFields(target)
37
39
  })
38
40
  })) });
39
- const groups = targets.flatMap((target, index) => {
41
+ const navigation = useMemo(() => searching ? navigationHits(meta, query, basePath) : [], [
42
+ searching,
43
+ meta,
44
+ query,
45
+ basePath
46
+ ]);
47
+ const records = targets.flatMap((target, index) => {
40
48
  const result = results[index];
41
49
  if (!searching || !result) return [];
42
50
  if (result.error) return [{
@@ -52,9 +60,10 @@ const useGlobalSearch = () => {
52
60
  hits
53
61
  }] : [];
54
62
  });
63
+ const groups = [...navigation, ...records];
55
64
  return {
56
- /** False when no resource can be searched; the top bar then shows no search box. */
57
- available: targets.length > 0,
65
+ /** False when there is nothing to find, no page, list or resource; the top bar then shows no search box. */
66
+ available: Object.keys(meta.pages).length > 0 || Object.keys(meta.resources).length > 0,
58
67
  placeholder: searchPlaceholder(targets),
59
68
  text,
60
69
  setText,
@@ -0,0 +1,38 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { useQueries } from "@tanstack/react-query";
4
+ import { createWhere, printFilter } from "@protobase/schema";
5
+ //#region src/app/use-incoming-counts.ts
6
+ const where$1 = createWhere();
7
+ /** How many records point at `key` through each relation: `undefined` while loading, left out when the list is refused. */
8
+ const useIncomingCounts = (relations, resources, key) => {
9
+ const client = useClient();
10
+ const counts = useQueries({ queries: relations.map((relation) => {
11
+ const filter = printFilter(where$1.eq(relation.field, key));
12
+ return {
13
+ queryKey: keys.list(relation.resource, filter, "", 1, "incoming"),
14
+ queryFn: async () => {
15
+ const page = await client.list(relation.resource, {
16
+ filter,
17
+ pageSize: 1,
18
+ count: "exact",
19
+ fields: resources[relation.resource].primaryKey
20
+ });
21
+ return {
22
+ count: page.totalSize ?? page.totalSizeEstimate,
23
+ exact: page.totalSize !== void 0
24
+ };
25
+ }
26
+ };
27
+ }) });
28
+ return relations.flatMap((relation, index) => {
29
+ const result = counts[index];
30
+ if (result.error) return [];
31
+ return [{
32
+ relation,
33
+ count: result.data
34
+ }];
35
+ });
36
+ };
37
+ //#endregion
38
+ export { useIncomingCounts };
@@ -1,12 +1,10 @@
1
1
  import { keys } from "../data/query-keys.js";
2
2
  import { useClient } from "../data/api-provider.js";
3
- import { humanize } from "../live/naming.js";
4
3
  import { fetchLabels } from "../live/relation-labels.js";
5
4
  import { useQueries, useQueryClient } from "@tanstack/react-query";
6
- import { appPath } from "@protobase/schema";
7
5
  //#region src/app/use-related-records.ts
8
- /** One entry per relation the record holds, named by the record it points at. The tenant is left out. */
9
- const useRelatedRecords = (model, view, resources, views, record, basePath) => {
6
+ /** Per relation the record holds, the address and name of the record it points at. The tenant is left out. */
7
+ const useRelatedRecords = (model, resources, views, record) => {
10
8
  const client = useClient();
11
9
  const queryClient = useQueryClient();
12
10
  const relations = Object.values(model.fields).filter((field) => field.type === "relation" && field.relation && field.name !== model.tenant && record[field.name] != null && resources[field.relation.resource]?.primaryKey.length === 1);
@@ -25,12 +23,7 @@ const useRelatedRecords = (model, view, resources, views, record, basePath) => {
25
23
  }));
26
24
  return {
27
25
  addresses: Object.fromEntries(relations.map((field, index) => [field.name, addresses[index]])),
28
- labels: Object.fromEntries(relations.map((field, index) => [field.name, labels[index]?.data])),
29
- records: relations.map((field, index) => ({
30
- kind: view?.fields[field.name]?.label ?? humanize(field.name),
31
- label: labels[index]?.data ?? String(record[field.name]),
32
- href: appPath(addresses[index], basePath)
33
- }))
26
+ labels: Object.fromEntries(relations.map((field, index) => [field.name, labels[index]?.data]))
34
27
  };
35
28
  };
36
29
  //#endregion
@@ -4,12 +4,13 @@ import { BottomBar } from "./bottom-bar.js";
4
4
  import { Drawer } from "./drawer.js";
5
5
  import { PageScrollContext, usePageGutter, usePageInsets } from "./page-scroll.js";
6
6
  import { Sidebar } from "./sidebar.js";
7
+ import { TopBarSlotContext } from "./top-bar-slot.js";
7
8
  import { TopBar } from "./top-bar.js";
8
9
  import { useEffect, useRef, useState } from "react";
9
10
  import { jsx, jsxs } from "react/jsx-runtime";
10
11
  //#region src/app-shell/app-shell.tsx
11
12
  const drawerId = "app-drawer";
12
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
13
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
13
14
  const desktop = useIsDesktop();
14
15
  const [drawerOpen, setDrawerOpen] = useState(false);
15
16
  useEffect(() => {
@@ -19,6 +20,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
19
20
  const [chosen, setPageScroll] = useState();
20
21
  const pageScroll = chosen ?? scrollsByDefault;
21
22
  const topBarRef = useRef(null);
23
+ const [topBarSlot, setTopBarSlot] = useState(null);
22
24
  const bottomBarRef = useRef(null);
23
25
  usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
24
26
  usePageGutter(pageScroll);
@@ -28,6 +30,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
28
30
  activeItem,
29
31
  user,
30
32
  workspace,
33
+ appName,
31
34
  forceExpanded,
32
35
  profileMenuOpen,
33
36
  groups: navGroups,
@@ -56,6 +59,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
56
59
  children: [
57
60
  /* @__PURE__ */ jsx(TopBar, {
58
61
  ref: topBarRef,
62
+ slotRef: setTopBarSlot,
59
63
  sticky: pageScroll,
60
64
  breadcrumb,
61
65
  onNavigate,
@@ -67,7 +71,10 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo,
67
71
  }),
68
72
  /* @__PURE__ */ jsx("main", {
69
73
  className: pageScroll ? "flex flex-1 flex-col" : "flex min-h-0 flex-1 flex-col",
70
- children
74
+ children: /* @__PURE__ */ jsx(TopBarSlotContext.Provider, {
75
+ value: topBarSlot,
76
+ children
77
+ })
71
78
  }),
72
79
  tabs && /* @__PURE__ */ jsx(BottomBar, {
73
80
  ref: bottomBarRef,
@@ -8,6 +8,7 @@ import { LogoMark, ShellLogoProps } from "./logo.js";
8
8
  import "./app-shell.js";
9
9
  import "./page-header.js";
10
10
  import "./page-scroll.js";
11
+ import "./top-bar.js";
11
12
  import "./sidebar.js";
12
13
  import "./status-dot.js";
13
14
  export type { SidebarMode };
@@ -3,9 +3,13 @@
3
3
  export declare const LogoMark: ({ className }: {
4
4
  className?: string;
5
5
  }) => import("react").JSX.Element;
6
- /** What the logo at the top of the sidebar gets: `compact` when the sidebar shows only icons, and the workspace's name. */
6
+ /**
7
+ * What the logo at the top of the sidebar gets: `compact` when the sidebar shows only icons, the workspace's name, and
8
+ * the app's name, such as `Property Finder` (`PROTOBASE_APP_NAME` or `createAuth`'s `appName`).
9
+ */
7
10
  export type ShellLogoProps = {
8
11
  compact: boolean;
9
12
  workspace?: string;
13
+ appName?: string;
10
14
  };
11
15
  //#endregion
@@ -29,12 +29,19 @@ const Wordmark = ({ className }) => /* @__PURE__ */ jsx("svg", {
29
29
  d: wordmarkPath
30
30
  })
31
31
  });
32
- /** Protobase's logo at the top of the sidebar, the workspace's name under the wordmark, or the mark alone when compact. */
33
- const Logo = ({ compact, workspace }) => /* @__PURE__ */ jsxs("div", {
32
+ const brandName = "Protobase";
33
+ /**
34
+ * The logo at the top of the sidebar: Protobase's mark, then the app's name, as the brand's wordmark when the app goes by
35
+ * Protobase's name, with the workspace's name under it; the mark alone when compact.
36
+ */
37
+ const Logo = ({ compact, workspace, appName }) => /* @__PURE__ */ jsxs("div", {
34
38
  className: "flex items-center gap-2.5 overflow-hidden",
35
39
  children: [/* @__PURE__ */ jsx(LogoMark, {}), !compact && /* @__PURE__ */ jsxs("div", {
36
40
  className: "flex min-w-0 flex-col gap-0.5 leading-tight",
37
- children: [/* @__PURE__ */ jsx(Wordmark, { className: "self-start" }), workspace && /* @__PURE__ */ jsx("div", {
41
+ children: [appName && appName !== brandName ? /* @__PURE__ */ jsx("div", {
42
+ className: "truncate text-sm font-semibold text-foreground",
43
+ children: appName
44
+ }) : /* @__PURE__ */ jsx(Wordmark, { className: "self-start" }), workspace && /* @__PURE__ */ jsx("div", {
38
45
  className: "truncate text-[11px] text-muted-foreground",
39
46
  children: workspace
40
47
  })]
@@ -10,7 +10,7 @@ import { SidebarGroup } from "./sidebar-group.js";
10
10
  import { LogIn } from "lucide-react";
11
11
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
12
  //#region src/app-shell/sidebar.tsx
13
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, signIn, organizations, roles, theme, sticky, logo: SidebarLogo = Logo }) => {
13
+ const Sidebar = ({ mode, activeItem, user, workspace, appName, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, signIn, organizations, roles, theme, sticky, logo: SidebarLogo = Logo }) => {
14
14
  const expanding = mode === "icon-expand";
15
15
  const expanded = expanding && forceExpanded;
16
16
  const inline = isTextMode(mode) || expanding;
@@ -24,7 +24,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
24
24
  className: cn("flex h-12 shrink-0 items-center px-3.5", mode === "icon-label" && "justify-center px-0"),
25
25
  children: /* @__PURE__ */ jsx(SidebarLogo, {
26
26
  compact: !inline,
27
- workspace
27
+ workspace,
28
+ appName
28
29
  })
29
30
  }),
30
31
  /* @__PURE__ */ jsx("div", {
@@ -0,0 +1,11 @@
1
+ import { createContext, useContext } from "react";
2
+ //#region src/app-shell/top-bar-slot.ts
3
+ /**
4
+ * The place in the top bar, below `md`, where a page puts what a mobile screen needs at hand, such as a record's actions
5
+ * menu, portalled into it; null until the top bar is drawn, and in a shell without one.
6
+ */
7
+ const TopBarSlotContext = createContext(null);
8
+ /** The top bar's place for the calling page's own controls on a mobile screen, or null when there is none. */
9
+ const useTopBarSlot = () => useContext(TopBarSlotContext);
10
+ //#endregion
11
+ export { TopBarSlotContext, useTopBarSlot };
@@ -0,0 +1,3 @@
1
+ import "./global-search.js";
2
+ import "./breadcrumb.js";
3
+ import { ReactNode, Ref } from "react";
@@ -6,8 +6,9 @@ import { useEffect, useRef, useState } from "react";
6
6
  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
- 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, sticky, ref }) => {
9
+ /** An icon button of the top bar on a mobile screen, as the menu, back and search buttons are. */
10
+ const topBarButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
11
+ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search, sticky, slotRef, ref }) => {
11
12
  const [searchOpen, setSearchOpen] = useState(false);
12
13
  const back = backTarget(breadcrumb);
13
14
  const desktopSearch = useRef(null);
@@ -33,7 +34,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
33
34
  "aria-expanded": Boolean(menuOpen),
34
35
  "aria-controls": menuControls,
35
36
  onClick: onMenu,
36
- className: cn(iconButton, "-ml-3"),
37
+ className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", "-ml-3"),
37
38
  children: /* @__PURE__ */ jsx(Menu, { className: "size-5" })
38
39
  }),
39
40
  back && /* @__PURE__ */ jsx("a", {
@@ -44,7 +45,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
44
45
  event.preventDefault();
45
46
  onNavigate(back.href);
46
47
  },
47
- className: cn(iconButton, !onMenu && "-ml-3"),
48
+ className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden", !onMenu && "-ml-3"),
48
49
  children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-5" })
49
50
  }),
50
51
  /* @__PURE__ */ jsx(Breadcrumb, {
@@ -53,20 +54,27 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
53
54
  }),
54
55
  /* @__PURE__ */ jsxs("div", {
55
56
  className: "ml-auto flex items-center gap-1 md:gap-3",
56
- children: [search && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
57
- className: "hidden md:block",
58
- children: /* @__PURE__ */ jsx(GlobalSearch, {
59
- ref: desktopSearch,
60
- search
57
+ children: [
58
+ search && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
59
+ className: "hidden md:block",
60
+ children: /* @__PURE__ */ jsx(GlobalSearch, {
61
+ ref: desktopSearch,
62
+ search
63
+ })
64
+ }), /* @__PURE__ */ jsx("button", {
65
+ type: "button",
66
+ "aria-label": "Search",
67
+ "aria-expanded": searchOpen,
68
+ onClick: () => setSearchOpen(true),
69
+ className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
70
+ children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
71
+ })] }),
72
+ actions,
73
+ /* @__PURE__ */ jsx("div", {
74
+ ref: slotRef,
75
+ className: "contents md:hidden"
61
76
  })
62
- }), /* @__PURE__ */ jsx("button", {
63
- type: "button",
64
- "aria-label": "Search",
65
- "aria-expanded": searchOpen,
66
- onClick: () => setSearchOpen(true),
67
- className: iconButton,
68
- children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
69
- })] }), actions]
77
+ ]
70
78
  }),
71
79
  search && searchOpen && /* @__PURE__ */ jsxs("div", {
72
80
  className: "absolute inset-0 z-20 flex items-center gap-1 bg-background px-3 md:hidden",
@@ -85,7 +93,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
85
93
  type: "button",
86
94
  "aria-label": "Close search",
87
95
  onClick: () => setSearchOpen(false),
88
- className: iconButton,
96
+ className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
89
97
  children: /* @__PURE__ */ jsx(X, { className: "size-5" })
90
98
  })]
91
99
  })
@@ -93,4 +101,4 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
93
101
  });
94
102
  };
95
103
  //#endregion
96
- export { TopBar };
104
+ export { TopBar, topBarButton };
@@ -1,10 +1,12 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Avatar } from "../primitives/avatar.js";
3
3
  import { Badge } from "../primitives/badge.js";
4
+ import { viewerZone, zoneLabel } from "../format/zone.js";
4
5
  import { userById } from "../mocks/users.js";
5
6
  import { fileShown, shownImage } from "../live/file-values.js";
6
7
  import { cellText } from "./cell-text.js";
7
8
  import { FileImage } from "../live/file-image.js";
9
+ import { ContactLink } from "../live/contact-link.js";
8
10
  import { valueColor } from "../live/value-color.js";
9
11
  import { TruncatedText } from "./truncated-text.js";
10
12
  import { Check, Paperclip } from "lucide-react";
@@ -18,7 +20,11 @@ const renderCell = (spec, value) => {
18
20
  if (value === null || value === void 0) return muted("—");
19
21
  const text = cellText(spec, value) ?? "";
20
22
  switch (spec.kind) {
21
- case "text": return /* @__PURE__ */ jsx(TruncatedText, { text });
23
+ case "text": return spec.link ? /* @__PURE__ */ jsx(ContactLink, {
24
+ format: spec.link,
25
+ value: text,
26
+ className: "block min-w-0 truncate"
27
+ }) : /* @__PURE__ */ jsx(TruncatedText, { text });
22
28
  case "id": return /* @__PURE__ */ jsx(TruncatedText, {
23
29
  text,
24
30
  className: "font-mono text-xs text-muted-foreground"
@@ -33,6 +39,7 @@ const renderCell = (spec, value) => {
33
39
  });
34
40
  case "datetime": return /* @__PURE__ */ jsx("span", {
35
41
  className: "tabular-nums text-muted-foreground",
42
+ title: `Shown in ${zoneLabel(Number(value), viewerZone())}`,
36
43
  children: text
37
44
  });
38
45
  case "number": return /* @__PURE__ */ jsx("span", {
@@ -2,15 +2,22 @@ import { cn } from "../lib/cn.js";
2
2
  import { Checkbox } from "../primitives/checkbox.js";
3
3
  import { gridTemplate } from "./grid-template.js";
4
4
  import { isRightAligned } from "./column-spec.js";
5
- import { HeaderMenu } from "./header-menu.js";
5
+ import { HeaderMenu, unsortableText } from "./header-menu.js";
6
6
  import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
7
- import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/data-grid/grid-header.tsx
9
9
  const SortIcon = ({ sorted }) => {
10
10
  if (sorted === "asc") return /* @__PURE__ */ jsx(ArrowUp, { className: "size-3 text-foreground" });
11
11
  if (sorted === "desc") return /* @__PURE__ */ jsx(ArrowDown, { className: "size-3 text-foreground" });
12
12
  return /* @__PURE__ */ jsx(ChevronsUpDown, { className: "size-3 opacity-0 group-hover/head:opacity-100" });
13
13
  };
14
+ const HeaderLabel = ({ spec }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
15
+ className: "truncate",
16
+ children: spec.header
17
+ }), spec.highlight && /* @__PURE__ */ jsx("span", {
18
+ className: "rounded bg-primary px-1 text-[10px] font-semibold uppercase leading-4 text-primary-foreground",
19
+ children: "New"
20
+ })] });
14
21
  const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing, minScale, pins = {}, lastPinned, scrolled, onTogglePin, onClearSelection }) => /* @__PURE__ */ jsxs("div", {
15
22
  className: "grid h-8 shrink-0 items-center border-b bg-surface text-xs font-medium text-muted-foreground",
16
23
  style: { gridTemplateColumns: gridTemplate(specs, gutter, trailing, minScale) },
@@ -37,27 +44,21 @@ const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing,
37
44
  "data-col-id": spec.id,
38
45
  style: pinned ? { left: pins[spec.id] } : void 0,
39
46
  className: cn("group/cell flex h-full min-w-0 items-center", pinned && "sticky z-10 bg-inherit", pinned && scrolled && spec.id === lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]", spec.highlight && "bg-primary-soft text-primary-text"),
40
- children: [/* @__PURE__ */ jsxs("button", {
47
+ children: [spec.sortable ? /* @__PURE__ */ jsxs("button", {
41
48
  type: "button",
42
49
  onClick: onSort,
43
- disabled: !spec.sortable,
44
- className: cn("group/head flex h-full min-w-0 flex-1 items-center gap-1 pl-3 pr-1 text-left", isRightAligned(spec.kind) && "justify-end", spec.sortable && "hover:text-foreground", sorted && "text-foreground"),
45
- children: [
46
- /* @__PURE__ */ jsx("span", {
47
- className: "truncate",
48
- children: spec.header
49
- }),
50
- spec.highlight && /* @__PURE__ */ jsx("span", {
51
- className: "rounded bg-primary px-1 text-[10px] font-semibold uppercase leading-4 text-primary-foreground",
52
- children: "New"
53
- }),
54
- spec.sortable && /* @__PURE__ */ jsx(SortIcon, { sorted })
55
- ]
50
+ className: cn("group/head flex h-full min-w-0 flex-1 items-center gap-1 pl-3 pr-1 text-left", isRightAligned(spec.kind) && "justify-end", "hover:text-foreground", sorted && "text-foreground"),
51
+ children: [/* @__PURE__ */ jsx(HeaderLabel, { spec }), /* @__PURE__ */ jsx(SortIcon, { sorted })]
52
+ }) : /* @__PURE__ */ jsx("div", {
53
+ title: unsortableText,
54
+ className: cn("flex h-full min-w-0 flex-1 items-center gap-1 pl-3 pr-1 text-left", isRightAligned(spec.kind) && "justify-end"),
55
+ children: /* @__PURE__ */ jsx(HeaderLabel, { spec })
56
56
  }), /* @__PURE__ */ jsx("div", {
57
57
  className: "pr-1.5",
58
58
  children: /* @__PURE__ */ jsx(HeaderMenu, {
59
59
  header: spec.header,
60
60
  pinned,
61
+ sortable: Boolean(spec.sortable),
61
62
  onTogglePin: () => onTogglePin(spec.id)
62
63
  })
63
64
  })]
@@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from "react";
2
2
  import { MoreHorizontal } from "lucide-react";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/data-grid/header-menu.tsx
5
- /** The "..." in a column header: for now, pinning the column to the left edge. */
6
- const HeaderMenu = ({ header, pinned, onTogglePin }) => {
5
+ /** Why a column header does nothing when pressed: a list sorts only on the columns its view makes sortable. */
6
+ const unsortableText = "This column can't be sorted";
7
+ /** The "..." in a column header: pinning the column to the left edge, and saying so when the list can't sort by it. */
8
+ const HeaderMenu = ({ header, pinned, sortable, onTogglePin }) => {
7
9
  const [open, setOpen] = useState(false);
8
10
  const root = useRef(null);
9
11
  useEffect(() => {
@@ -29,10 +31,18 @@ const HeaderMenu = ({ header, pinned, onTogglePin }) => {
29
31
  onClick: () => setOpen(!open),
30
32
  className: "relative inline-flex size-5 items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground focus-visible:opacity-100 after:absolute after:-inset-2 after:content-[''] md:opacity-0 md:group-hover/cell:opacity-100 aria-expanded:opacity-100",
31
33
  children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-3.5" })
32
- }), open && /* @__PURE__ */ jsx("ul", {
34
+ }), open && /* @__PURE__ */ jsxs("ul", {
33
35
  role: "menu",
34
36
  className: "absolute right-0 top-full z-30 mt-1 min-w-32 rounded-md border bg-background py-1 text-body font-normal text-foreground shadow-pop",
35
- children: /* @__PURE__ */ jsx("li", {
37
+ children: [!sortable && /* @__PURE__ */ jsx("li", {
38
+ role: "none",
39
+ children: /* @__PURE__ */ jsx("span", {
40
+ role: "menuitem",
41
+ "aria-disabled": "true",
42
+ className: "block min-h-9 whitespace-nowrap px-3 py-1.5 text-muted-foreground",
43
+ children: "This column can't be sorted"
44
+ })
45
+ }), /* @__PURE__ */ jsx("li", {
36
46
  role: "none",
37
47
  children: /* @__PURE__ */ jsx("button", {
38
48
  type: "button",
@@ -44,9 +54,9 @@ const HeaderMenu = ({ header, pinned, onTogglePin }) => {
44
54
  },
45
55
  children: pinned ? "Unpin" : "Pin to left"
46
56
  })
47
- })
57
+ })]
48
58
  })]
49
59
  });
50
60
  };
51
61
  //#endregion
52
- export { HeaderMenu };
62
+ export { HeaderMenu, unsortableText };
@@ -1,56 +1,19 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { RouteLink } from "../primitives/route-link.js";
3
- import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { usePopoverMenu } from "../primitives/popover-menu.js";
4
4
  import { MoreHorizontal } from "lucide-react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { createPortal } from "react-dom";
7
7
  //#region src/data-grid/row-menu.tsx
8
8
  /** The actions that apply to a row; a row with none gets no menu at all. */
9
9
  const availableActions = (actions, row) => actions.filter((action) => action.available?.(row) ?? true);
10
- const gap = 4;
11
- /** Below the button, or above it when the viewport has no room underneath. */
12
- const placeMenu = (button, menuHeight) => {
13
- const below = button.bottom + gap;
14
- return {
15
- top: below + menuHeight > window.innerHeight - gap ? Math.max(gap, button.top - gap - menuHeight) : below,
16
- right: window.innerWidth - button.right
17
- };
18
- };
19
10
  /**
20
11
  * The "..." button at the end of a row, with a small menu of the row's actions. The menu is portalled to the body:
21
12
  * every grid row is its own stacking context and the grid body clips, so a menu inside the row would sit under the
22
13
  * rows below it.
23
14
  */
24
15
  const RowMenu = ({ row, actions }) => {
25
- const [open, setOpen] = useState(false);
26
- const [place, setPlace] = useState();
27
- const button = useRef(null);
28
- const menu = useRef(null);
29
- useLayoutEffect(() => {
30
- if (!open || !button.current || !menu.current) {
31
- setPlace(void 0);
32
- return;
33
- }
34
- setPlace(placeMenu(button.current.getBoundingClientRect(), menu.current.offsetHeight));
35
- }, [open]);
36
- useEffect(() => {
37
- if (!open) return;
38
- const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
39
- const close = (event) => {
40
- if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
41
- };
42
- const dismiss = () => setOpen(false);
43
- document.addEventListener("mousedown", close);
44
- document.addEventListener("keydown", close);
45
- document.addEventListener("scroll", dismiss, true);
46
- window.addEventListener("resize", dismiss);
47
- return () => {
48
- document.removeEventListener("mousedown", close);
49
- document.removeEventListener("keydown", close);
50
- document.removeEventListener("scroll", dismiss, true);
51
- window.removeEventListener("resize", dismiss);
52
- };
53
- }, [open]);
16
+ const { open, setOpen, place, button, menu } = usePopoverMenu();
54
17
  return /* @__PURE__ */ jsxs("div", {
55
18
  className: "relative flex justify-center bg-inherit",
56
19
  onClick: (event) => event.stopPropagation(),