@protobase/ui 0.9.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.
Files changed (74) hide show
  1. package/dist/app/app.js +67 -28
  2. package/dist/app/batch-failure.js +1 -1
  3. package/dist/app/bottom-bar-from-meta.js +37 -0
  4. package/dist/app/breadcrumb-for.js +18 -9
  5. package/dist/app/create/create-page.js +12 -6
  6. package/dist/app/current-address.d.ts +26 -0
  7. package/dist/app/current-address.js +41 -0
  8. package/dist/app/custom-section.js +37 -0
  9. package/dist/app/delete/record-diff.js +1 -1
  10. package/dist/app/displays/cards-display.js +76 -0
  11. package/dist/app/displays/custom-display.js +28 -0
  12. package/dist/app/displays/display-context.d.ts +21 -0
  13. package/dist/app/displays/display-context.js +21 -0
  14. package/dist/app/displays/display-controls.js +55 -0
  15. package/dist/app/displays/display-frame.js +30 -0
  16. package/dist/app/displays/list-displays.d.ts +11 -0
  17. package/dist/app/displays/list-displays.js +62 -0
  18. package/dist/app/displays/record-link.d.ts +19 -0
  19. package/dist/app/displays/record-link.js +25 -0
  20. package/dist/app/displays/use-display-rows.js +28 -0
  21. package/dist/app/field-editor.js +4 -4
  22. package/dist/app/index.html +3 -2
  23. package/dist/app/list-page.js +87 -37
  24. package/dist/app/main.js +3 -2
  25. package/dist/app/nav-from-meta.js +9 -2
  26. package/dist/app/nav-recent.js +6 -2
  27. package/dist/app/pages/action-href.js +1 -3
  28. package/dist/app/pages/blocks/card-row.js +1 -1
  29. package/dist/app/pages/blocks/link.js +20 -11
  30. package/dist/app/pages/blocks/modal-form.js +1 -1
  31. package/dist/app/pages/blocks/record-card.js +1 -1
  32. package/dist/app/pages/blocks/stat.js +7 -4
  33. package/dist/app/pages/blocks/table.js +1 -1
  34. package/dist/app/pages/field-value.js +8 -4
  35. package/dist/app/pages/merge-ui.js +3 -1
  36. package/dist/app/pages/problem.js +10 -0
  37. package/dist/app/pages/project-ui.d.ts +12 -4
  38. package/dist/app/pages/record-actions.js +6 -3
  39. package/dist/app/pages/record-table.js +7 -3
  40. package/dist/app/pages/render-node.js +3 -6
  41. package/dist/app/pages/ui-from-module.d.ts +9 -0
  42. package/dist/app/pages/ui-from-module.js +23 -0
  43. package/dist/app/pages/use-run-action.js +7 -7
  44. package/dist/app/record-page.js +51 -26
  45. package/dist/app/related-section.js +1 -1
  46. package/dist/app/router.js +4 -20
  47. package/dist/app/search/search-model.js +6 -2
  48. package/dist/app/search/use-global-search.js +1 -1
  49. package/dist/app/use-app-address.d.ts +24 -0
  50. package/dist/app/use-app-address.js +47 -0
  51. package/dist/app/use-related-records.js +8 -2
  52. package/dist/app/user-menu-from-meta.js +9 -2
  53. package/dist/app-shell/app-shell.d.ts +1 -0
  54. package/dist/app-shell/app-shell.js +27 -14
  55. package/dist/app-shell/bottom-bar.d.ts +1 -0
  56. package/dist/app-shell/bottom-bar.js +57 -0
  57. package/dist/app-shell/index.d.ts +1 -0
  58. package/dist/app-shell/profile-menu.js +35 -26
  59. package/dist/app-shell/sidebar.js +3 -2
  60. package/dist/app-shell/switch-group.js +29 -0
  61. package/dist/auth/auth-provider.js +5 -1
  62. package/dist/auth/sign-in-form.js +23 -12
  63. package/dist/auth/sign-in-page.js +3 -2
  64. package/dist/data/meta-data.d.ts +3 -1
  65. package/dist/data/use-meta.js +1 -0
  66. package/dist/index.d.ts +7 -1
  67. package/dist/index.js +5 -1
  68. package/dist/live/audit-fields.js +5 -0
  69. package/dist/live/naming.js +2 -2
  70. package/dist/live/use-live-rows.js +7 -2
  71. package/dist/primitives/segmented.js +8 -5
  72. package/dist/roles/acting-user.js +11 -0
  73. package/dist/roles/use-active-role.js +33 -0
  74. package/package.json +4 -4
@@ -2,6 +2,7 @@ import { humanize } from "../live/naming.js";
2
2
  import { iconFor } from "./icons.js";
3
3
  import { menuIcon } from "./menu-icons.js";
4
4
  import { ExternalLink, LayoutDashboard } from "lucide-react";
5
+ import { appPath } from "@protobase/schema";
5
6
  //#region src/app/user-menu-from-meta.ts
6
7
  /** The resources and pages the user menu holds; the sidebar leaves them out. */
7
8
  const userMenuResources = (meta) => new Set((meta.userMenu?.items ?? []).flatMap((item) => item.kind === "resource" ? [item.resource] : item.kind === "page" ? [item.page] : []));
@@ -20,7 +21,10 @@ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.ite
20
21
  id: item.page,
21
22
  label: item.label ?? page?.title ?? humanize(item.page),
22
23
  icon: menuIcon(item.icon ?? page?.icon, LayoutDashboard),
23
- href: `${basePath}/${item.page}`,
24
+ href: appPath({
25
+ kind: "page",
26
+ page: item.page
27
+ }, basePath),
24
28
  active: item.page === activeResource
25
29
  };
26
30
  }
@@ -28,7 +32,10 @@ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.ite
28
32
  id: item.resource,
29
33
  label: item.label ?? meta.views[item.resource]?.names?.plural ?? humanize(item.resource),
30
34
  icon: menuIcon(item.icon, iconFor(item.resource)),
31
- href: `${basePath}/${item.resource}`,
35
+ href: appPath({
36
+ kind: "list",
37
+ resource: item.resource
38
+ }, basePath),
32
39
  active: item.resource === activeResource
33
40
  };
34
41
  });
@@ -1,3 +1,4 @@
1
+ import "./bottom-bar.js";
1
2
  import "./nav.js";
2
3
  import "./profile-menu.js";
3
4
  import "./global-search.js";
@@ -1,5 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
+ import { BottomBar } from "./bottom-bar.js";
3
4
  import { Drawer } from "./drawer.js";
4
5
  import { Sidebar } from "./sidebar.js";
5
6
  import { TopBar } from "./top-bar.js";
@@ -7,12 +8,13 @@ import { useEffect, useState } from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  //#region src/app-shell/app-shell.tsx
9
10
  const drawerId = "app-drawer";
10
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, onNavigate, onSignOut, organizations, search, className, children }) => {
11
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, organizations, roles, search, className, children }) => {
11
12
  const desktop = useIsDesktop();
12
13
  const [drawerOpen, setDrawerOpen] = useState(false);
13
14
  useEffect(() => {
14
15
  if (desktop) setDrawerOpen(false);
15
16
  }, [desktop]);
17
+ const tabs = !desktop && bottomBar && bottomBar.length > 0 ? bottomBar : void 0;
16
18
  const sidebar = (mode) => /* @__PURE__ */ jsx(Sidebar, {
17
19
  mode,
18
20
  activeItem,
@@ -24,7 +26,8 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
24
26
  userMenu,
25
27
  onNavigate,
26
28
  onSignOut,
27
- organizations
29
+ organizations,
30
+ roles
28
31
  });
29
32
  return /* @__PURE__ */ jsxs("div", {
30
33
  className: cn("flex h-full min-h-0 w-full overflow-hidden bg-background", className),
@@ -37,18 +40,28 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
37
40
  }),
38
41
  /* @__PURE__ */ jsxs("div", {
39
42
  className: "flex min-w-0 flex-1 flex-col",
40
- children: [/* @__PURE__ */ jsx(TopBar, {
41
- breadcrumb,
42
- onNavigate,
43
- actions,
44
- onMenu: desktop ? void 0 : () => setDrawerOpen(true),
45
- menuOpen: drawerOpen,
46
- menuControls: drawerId,
47
- search
48
- }), /* @__PURE__ */ jsx("main", {
49
- className: "flex min-h-0 flex-1 flex-col",
50
- children
51
- })]
43
+ children: [
44
+ /* @__PURE__ */ jsx(TopBar, {
45
+ breadcrumb,
46
+ onNavigate,
47
+ actions,
48
+ onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
49
+ menuOpen: drawerOpen,
50
+ menuControls: drawerId,
51
+ search
52
+ }),
53
+ /* @__PURE__ */ jsx("main", {
54
+ className: "flex min-h-0 flex-1 flex-col",
55
+ children
56
+ }),
57
+ tabs && /* @__PURE__ */ jsx(BottomBar, {
58
+ tabs,
59
+ onNavigate,
60
+ onMore: () => setDrawerOpen(true),
61
+ moreOpen: drawerOpen,
62
+ moreControls: drawerId
63
+ })
64
+ ]
52
65
  }),
53
66
  rightPanel
54
67
  ]
@@ -0,0 +1 @@
1
+ import "lucide-react";
@@ -0,0 +1,57 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Ellipsis } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app-shell/bottom-bar.tsx
5
+ const inApp = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
6
+ const tab = "flex min-h-[49px] min-w-0 flex-1 flex-col items-center justify-center gap-1 pb-1 pt-1.5 text-[10px] font-medium leading-none";
7
+ /**
8
+ * Tabs along the bottom of a phone, as on iOS: icon above label, the current screen in the accent colour, and "More"
9
+ * last, which opens the navigation drawer and is the current tab on screens the others do not hold. The padding below
10
+ * keeps the tabs clear of the iPhone home indicator.
11
+ */
12
+ const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls }) => {
13
+ const moreCurrent = !tabs.some((item) => item.active);
14
+ return /* @__PURE__ */ jsxs("nav", {
15
+ "aria-label": "Tabs",
16
+ className: "flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden",
17
+ children: [tabs.map((item) => {
18
+ const Icon = item.icon;
19
+ return /* @__PURE__ */ jsxs("a", {
20
+ href: item.href,
21
+ "aria-current": item.active ? "page" : void 0,
22
+ ...item.external ? {
23
+ target: "_blank",
24
+ rel: "noreferrer"
25
+ } : {},
26
+ onClick: (event) => {
27
+ if (item.external || !onNavigate || !inApp(event)) return;
28
+ event.preventDefault();
29
+ onNavigate(item.href);
30
+ },
31
+ className: cn(tab, item.active ? "text-primary-text" : "text-muted-foreground"),
32
+ children: [/* @__PURE__ */ jsx(Icon, {
33
+ className: "size-6",
34
+ strokeWidth: item.active ? 2.2 : 1.9,
35
+ "aria-hidden": true
36
+ }), /* @__PURE__ */ jsx("span", {
37
+ className: "max-w-full truncate px-0.5",
38
+ children: item.label
39
+ })]
40
+ }, item.id);
41
+ }), /* @__PURE__ */ jsxs("button", {
42
+ type: "button",
43
+ "aria-expanded": moreOpen,
44
+ "aria-controls": moreControls,
45
+ "data-current": moreCurrent || void 0,
46
+ onClick: onMore,
47
+ className: cn(tab, moreCurrent ? "text-primary-text" : "text-muted-foreground"),
48
+ children: [/* @__PURE__ */ jsx(Ellipsis, {
49
+ className: "size-6",
50
+ strokeWidth: moreCurrent ? 2.2 : 1.9,
51
+ "aria-hidden": true
52
+ }), "More"]
53
+ })]
54
+ });
55
+ };
56
+ //#endregion
57
+ export { BottomBar };
@@ -1,3 +1,4 @@
1
+ import "./bottom-bar.js";
1
2
  import "./nav.js";
2
3
  import "./profile-menu.js";
3
4
  import { SidebarMode, sidebarModes } from "./sidebar-mode.js";
@@ -1,13 +1,15 @@
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 { SwitchGroup } from "./switch-group.js";
4
5
  import { useEffect, useRef, useState } from "react";
5
- import { Check, ChevronsUpDown, LogOut } from "lucide-react";
6
+ import { ChevronsUpDown, LogOut } from "lucide-react";
6
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/app-shell/profile-menu.tsx
8
9
  const menuRow = "flex min-h-11 w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] text-muted-foreground hover:bg-muted md:min-h-0";
9
10
  /** The person at the bottom of the sidebar; the menu is fixed to the viewport so a narrow sidebar cannot clip it. */
10
- const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations }) => {
11
+ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations, roles }) => {
12
+ const activeRole = roles?.roles.find((role) => role.name === roles.active);
11
13
  const [local, setLocal] = useState(false);
12
14
  const open = controlled ?? local;
13
15
  const root = useRef(null);
@@ -77,30 +79,37 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
77
79
  children: user.email
78
80
  })]
79
81
  }),
80
- organizations && (organizations.current || organizations.others.length > 0) && /* @__PURE__ */ jsxs("div", {
81
- className: "border-b py-1",
82
- role: "group",
83
- "aria-label": "Organizations",
84
- children: [organizations.current && /* @__PURE__ */ jsxs("div", {
85
- className: cn(menuRow, "text-foreground hover:bg-transparent"),
86
- "aria-current": "true",
87
- children: [/* @__PURE__ */ jsx(Check, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
88
- className: "truncate",
89
- children: organizations.current.name
90
- })]
91
- }), organizations.others.map((organization) => /* @__PURE__ */ jsxs("button", {
92
- type: "button",
93
- role: "menuitem",
94
- onClick: () => {
95
- setLocal(false);
96
- organizations.onSwitch(organization.id);
97
- },
98
- className: menuRow,
99
- children: [/* @__PURE__ */ jsx("span", { className: "size-4" }), /* @__PURE__ */ jsx("span", {
100
- className: "truncate",
101
- children: organization.name
102
- })]
103
- }, organization.id))]
82
+ organizations && (organizations.current || organizations.others.length > 0) && /* @__PURE__ */ jsx(SwitchGroup, {
83
+ label: "Organizations",
84
+ ...organizations.current && { current: {
85
+ id: organizations.current.id,
86
+ label: organizations.current.name
87
+ } },
88
+ others: organizations.others.map(({ id, name }) => ({
89
+ id,
90
+ label: name
91
+ })),
92
+ rowClassName: menuRow,
93
+ onSwitch: (id) => {
94
+ setLocal(false);
95
+ organizations.onSwitch(id);
96
+ }
97
+ }),
98
+ roles && roles.roles.length > 1 && /* @__PURE__ */ jsx(SwitchGroup, {
99
+ label: "Roles",
100
+ ...activeRole && { current: {
101
+ id: activeRole.name,
102
+ label: activeRole.label
103
+ } },
104
+ others: roles.roles.filter((role) => role !== activeRole).map(({ name, label }) => ({
105
+ id: name,
106
+ label
107
+ })),
108
+ rowClassName: menuRow,
109
+ onSwitch: (name) => {
110
+ setLocal(false);
111
+ roles.onSwitch(name);
112
+ }
104
113
  }),
105
114
  items.length > 0 && /* @__PURE__ */ jsx("div", {
106
115
  className: cn(onSignOut && "border-b"),
@@ -7,7 +7,7 @@ import { SidebarItem } from "./sidebar-item.js";
7
7
  import { SidebarGroup } from "./sidebar-group.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app-shell/sidebar.tsx
10
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, organizations }) => {
10
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, organizations, roles }) => {
11
11
  const expanding = mode === "icon-expand";
12
12
  const expanded = expanding && forceExpanded;
13
13
  const inline = isTextMode(mode) || expanding;
@@ -54,7 +54,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
54
54
  items: userMenu,
55
55
  onNavigate,
56
56
  onSignOut,
57
- organizations
57
+ organizations,
58
+ roles
58
59
  })
59
60
  })
60
61
  ]
@@ -0,0 +1,29 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Check } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app-shell/switch-group.tsx
5
+ /** A group of the profile menu that switches between options: the current one checked, the others a click away. */
6
+ const SwitchGroup = ({ label, current, others, rowClassName, onSwitch }) => /* @__PURE__ */ jsxs("div", {
7
+ className: "border-b py-1",
8
+ role: "group",
9
+ "aria-label": label,
10
+ children: [current && /* @__PURE__ */ jsxs("div", {
11
+ className: cn(rowClassName, "text-foreground hover:bg-transparent"),
12
+ "aria-current": "true",
13
+ children: [/* @__PURE__ */ jsx(Check, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
14
+ className: "truncate",
15
+ children: current.label
16
+ })]
17
+ }), others.map((option) => /* @__PURE__ */ jsxs("button", {
18
+ type: "button",
19
+ role: "menuitem",
20
+ onClick: () => onSwitch(option.id),
21
+ className: rowClassName,
22
+ children: [/* @__PURE__ */ jsx("span", { className: "size-4" }), /* @__PURE__ */ jsx("span", {
23
+ className: "truncate",
24
+ children: option.label
25
+ })]
26
+ }, option.id))]
27
+ });
28
+ //#endregion
29
+ export { SwitchGroup };
@@ -14,6 +14,7 @@ const AuthProvider = ({ session: given, children }) => {
14
14
  const [state, setState] = useState({ kind: "loading" });
15
15
  const [signInMethods, setSignInMethods] = useState(["password"]);
16
16
  const [passwordReset, setPasswordReset] = useState(false);
17
+ const [passkeyDomains, setPasskeyDomains] = useState([]);
17
18
  const [socialProviders, setSocialProviders] = useState([]);
18
19
  const [platformSignIn, setPlatformSignIn] = useState();
19
20
  const [staffSignIn, setStaffSignIn] = useState();
@@ -43,6 +44,7 @@ const AuthProvider = ({ session: given, children }) => {
43
44
  session.status().then(async (status) => {
44
45
  setSignInMethods(status.signInMethods);
45
46
  setPasswordReset(status.passwordReset);
47
+ setPasskeyDomains(status.passkeyDomains ?? []);
46
48
  setSocialProviders(status.socialProviders);
47
49
  setPlatformSignIn(status.platformSignIn);
48
50
  setStaffSignIn(status.staffSignIn);
@@ -61,6 +63,7 @@ const AuthProvider = ({ session: given, children }) => {
61
63
  session,
62
64
  signInMethods,
63
65
  passwordReset,
66
+ passkeyDomains,
64
67
  socialProviders,
65
68
  signInProviders: socialProviders.map((id) => ({
66
69
  id,
@@ -73,7 +76,7 @@ const AuthProvider = ({ session: given, children }) => {
73
76
  signIn: async (email, password) => finish(await session.signIn(email, password)),
74
77
  sendSignInCode: (email) => session.sendSignInCode(email),
75
78
  signInWithCode: async (email, code) => finish(await session.signInWithCode(email, code)),
76
- signInWithPasskey: async () => enter(await session.signInWithPasskey()),
79
+ signInWithPasskey: async (domain) => enter(await session.signInWithPasskey(domain)),
77
80
  verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),
78
81
  signInSocial: async (provider) => {
79
82
  window.location.assign(await session.signInSocial(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
@@ -94,6 +97,7 @@ const AuthProvider = ({ session: given, children }) => {
94
97
  session,
95
98
  signInMethods,
96
99
  passwordReset,
100
+ passkeyDomains,
97
101
  socialProviders,
98
102
  platformSignIn,
99
103
  staffSignIn,
@@ -13,7 +13,7 @@ const Divider = () => /* @__PURE__ */ jsxs("div", {
13
13
  /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
14
14
  ]
15
15
  });
16
- const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, onForgotPassword, providers = [], onContinueWith }) => {
16
+ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, passkeyDomains = [], onForgotPassword, providers = [], onContinueWith }) => {
17
17
  const [email, setEmail] = useState("");
18
18
  const [password, setPassword] = useState("");
19
19
  const emailInput = useRef(null);
@@ -36,17 +36,28 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["pass
36
36
  }),
37
37
  (offered.length > 0 || withPasskey) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
38
38
  className: "flex flex-col gap-2",
39
- children: [offered.map((provider) => /* @__PURE__ */ jsxs(Button, {
40
- onClick: () => onContinueWith?.(provider.id),
41
- disabled: busy,
42
- className: "min-h-10 w-full",
43
- children: ["Continue with ", provider.name]
44
- }, provider.id)), withPasskey && /* @__PURE__ */ jsxs(Button, {
45
- onClick: onPasskey,
46
- disabled: busy,
47
- className: "min-h-10 w-full",
48
- children: [/* @__PURE__ */ jsx(KeyRound, { className: "size-4" }), "Sign in with a passkey"]
49
- })]
39
+ children: [
40
+ offered.map((provider) => /* @__PURE__ */ jsxs(Button, {
41
+ onClick: () => onContinueWith?.(provider.id),
42
+ disabled: busy,
43
+ className: "min-h-10 w-full",
44
+ children: ["Continue with ", provider.name]
45
+ }, provider.id)),
46
+ withPasskey && /* @__PURE__ */ jsxs(Button, {
47
+ onClick: () => onPasskey?.(),
48
+ disabled: busy,
49
+ className: "min-h-10 w-full",
50
+ children: [/* @__PURE__ */ jsx(KeyRound, { className: "size-4" }), "Sign in with a passkey"]
51
+ }),
52
+ withPasskey && passkeyDomains.slice(1).map((domain) => /* @__PURE__ */ jsxs(Button, {
53
+ variant: "ghost",
54
+ size: "sm",
55
+ onClick: () => onPasskey?.(domain),
56
+ disabled: busy,
57
+ className: "w-full",
58
+ children: ["Use a passkey made for ", domain]
59
+ }, domain))
60
+ ]
50
61
  }), withPassword || withCode ? /* @__PURE__ */ jsx(Divider, {}) : error && /* @__PURE__ */ jsx("p", {
51
62
  role: "alert",
52
63
  className: "mt-4 text-xs font-medium text-danger-text",
@@ -6,7 +6,7 @@ import { AuthError } from "@protobase/client";
6
6
  import { jsx } from "react/jsx-runtime";
7
7
  //#region src/auth/sign-in-page.tsx
8
8
  const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: initialError }) => {
9
- const { signIn, signInSocial, signInProviders, signInMethods, sendSignInCode, signInWithPasskey } = useAuth();
9
+ const { signIn, signInSocial, signInProviders, signInMethods, passkeyDomains, sendSignInCode, signInWithPasskey } = useAuth();
10
10
  const [busy, setBusy] = useState(false);
11
11
  const [error, setError] = useState(initialError);
12
12
  const attempt = async (start, message) => {
@@ -29,7 +29,8 @@ const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: in
29
29
  onForgotPassword,
30
30
  providers: signInProviders,
31
31
  onContinueWith: (provider) => void attempt(() => signInSocial(provider), signInMessage),
32
- onPasskey: () => void attempt(signInWithPasskey, authMessage),
32
+ passkeyDomains,
33
+ onPasskey: (domain) => void attempt(() => signInWithPasskey(domain), authMessage),
33
34
  onSendCode: (email) => void attempt(async () => {
34
35
  await sendSignInCode(email);
35
36
  onCodeSent(email);
@@ -1,5 +1,5 @@
1
1
  import { ResourcePermissions } from "@protobase/client";
2
- import { ResourceModel, UserMenuModel, ViewModel } from "@protobase/schema";
2
+ import { BottomBarModel, ResourceModel, UserMenuModel, ViewModel } from "@protobase/schema";
3
3
  import { PageModel } from "@protobase/layout";
4
4
  //#region src/data/meta-data.d.ts
5
5
  export type MetaData = {
@@ -10,6 +10,8 @@ export type MetaData = {
10
10
  pages: Record<string, PageModel>;
11
11
  permissions: Record<string, ResourcePermissions>;
12
12
  userMenu?: UserMenuModel;
13
+ /** The tabs of the user's bottom bar on phones, when one names their active role. */
14
+ bottomBar?: Omit<BottomBarModel, 'roles'>;
13
15
  /** The assistant backend, when the app has one and the user has the admin or ai role. */
14
16
  assistant?: {
15
17
  url: string;
@@ -27,6 +27,7 @@ const useMeta = () => {
27
27
  pages: Object.fromEntries((result.meta.pages ?? []).map((model) => [model.name, model])),
28
28
  permissions: result.meta.permissions ?? {},
29
29
  ...result.meta.userMenu && { userMenu: result.meta.userMenu },
30
+ ...result.meta.bottomBar && { bottomBar: result.meta.bottomBar },
30
31
  ...result.meta.assistant && { assistant: result.meta.assistant },
31
32
  ...result.meta.runtime && { runtime: result.meta.runtime }
32
33
  };
package/dist/index.d.ts CHANGED
@@ -9,7 +9,13 @@ import { useRecord } from "./data/use-record.js";
9
9
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
10
10
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
11
11
  import { mergeUi } from "./app/pages/merge-ui.js";
12
+ import { uiFromModule } from "./app/pages/ui-from-module.js";
12
13
  import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
14
+ import { CurrentAddress, ListAddress, ListChange } from "./app/current-address.js";
15
+ import { AppLink, useAppAddress } from "./app/use-app-address.js";
16
+ import { ListDisplayRow } from "./app/displays/list-displays.js";
17
+ import { ListDisplay, useListDisplay } from "./app/displays/display-context.js";
18
+ import { RecordLink } from "./app/displays/record-link.js";
13
19
  import { useApiToken } from "./auth/use-api-token.js";
14
20
  import { AssistantDock, AssistantDockProps } from "./assistant-dock/assistant-dock.js";
15
21
  import { PartView } from "./assistant-dock/part-view.js";
@@ -30,4 +36,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
30
36
  import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
31
37
  import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
32
38
  import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
33
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type MetaData, PartView, type ProjectUi, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
39
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, PartView, type ProjectUi, RecordLink, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
package/dist/index.js CHANGED
@@ -18,10 +18,14 @@ import { AssistantDock } from "./assistant-dock/assistant-dock.js";
18
18
  import { useAssistant } from "./assistant-dock/use-assistant.js";
19
19
  import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
20
20
  import { useMeta } from "./data/use-meta.js";
21
+ import { AppLink, useAppAddress } from "./app/use-app-address.js";
21
22
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
23
+ import { useListDisplay } from "./app/displays/display-context.js";
22
24
  import { useLayoutRecord } from "./app/pages/record-scope.js";
25
+ import { uiFromModule } from "./app/pages/ui-from-module.js";
23
26
  import { mergeUi } from "./app/pages/merge-ui.js";
24
27
  import { useList } from "./data/use-list.js";
25
28
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
26
29
  import { App } from "./app/app.js";
27
- export { ActionCard, ApiProvider, App, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
30
+ import { RecordLink } from "./app/displays/record-link.js";
31
+ export { ActionCard, ApiProvider, App, AppLink, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, RecordLink, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, useRecord, useSeries, useUpdateRecord };
@@ -0,0 +1,5 @@
1
+ //#region src/live/audit-fields.ts
2
+ /** The field that holds when a record was created or last updated: `created_at` or `createdAt`, as the model names it. */
3
+ const auditField = (model, kind) => [`${kind}_at`, `${kind}At`].find((name) => model.fields[name]);
4
+ //#endregion
5
+ export { auditField };
@@ -1,8 +1,8 @@
1
1
  //#region src/live/naming.ts
2
2
  const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
3
- /** `companyId` becomes "Company", `discountPercent` becomes "Discount percent". */
3
+ /** `companyId` and `company_id` become "Company", `discountPercent` and `discount_percent` become "Discount percent". */
4
4
  const humanize = (name) => {
5
- const words = name.replace(/(Id|Code)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
5
+ const words = name.replace(/(Id|Code|_id|_code)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/_+/g, " ").trim().toLowerCase();
6
6
  return capitalise(words || name);
7
7
  };
8
8
  //#endregion
@@ -4,7 +4,10 @@ import { createGridLoader } from "./grid-loader.js";
4
4
  import { useMemo } from "react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  //#region src/live/use-live-rows.ts
7
- /** A grid row source backed by the API; `total` is the server's estimate from the first page. */
7
+ /**
8
+ * A grid row source backed by the API; `total` is the server's estimate from the first page. `page` loads one page of
9
+ * the same rows by number, and `key` names them in the query cache, for list displays that page through them.
10
+ */
8
11
  const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
9
12
  const client = useClient();
10
13
  const queryClient = useQueryClient();
@@ -38,7 +41,9 @@ const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
38
41
  loadRows: loader.loadRows
39
42
  } : void 0, [first.data, loader]),
40
43
  error: first.error,
41
- isLoading: first.isLoading
44
+ isLoading: first.isLoading,
45
+ page: loader.page,
46
+ key: [...keys.first(model.name, filter, orderBy), columnKey]
42
47
  };
43
48
  };
44
49
  //#endregion
@@ -1,13 +1,16 @@
1
1
  import { cn } from "../lib/cn.js";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  //#region src/primitives/segmented.tsx
4
- const Segmented = ({ value, options, onChange }) => /* @__PURE__ */ jsx("span", {
4
+ const Segmented = ({ value, options, onChange, label }) => /* @__PURE__ */ jsx("span", {
5
+ role: "group",
6
+ "aria-label": label,
5
7
  className: "inline-flex h-7 rounded-md bg-muted p-0.5",
6
- children: options.map((option) => /* @__PURE__ */ jsx("button", {
8
+ children: options.map((option) => /* @__PURE__ */ jsxs("button", {
7
9
  type: "button",
10
+ "aria-pressed": option.value === value,
8
11
  onClick: () => onChange(option.value),
9
- className: cn("rounded px-2.5 text-xs font-medium transition-colors", option.value === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
10
- children: option.label
12
+ className: cn("inline-flex items-center gap-1.5 rounded px-2.5 text-xs font-medium transition-colors", option.value === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
13
+ children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), option.label]
11
14
  }, option.value))
12
15
  });
13
16
  //#endregion
@@ -0,0 +1,11 @@
1
+ //#region src/roles/acting-user.ts
2
+ /**
3
+ * The signed-in user as the session acts: with the active role alone, as its tokens carry it, so what the shell shows
4
+ * by role follows a switch. As signed in until the active role is read, or for someone without a role.
5
+ */
6
+ const actingUser = (user, role) => role?.active ? {
7
+ ...user,
8
+ role: role.active
9
+ } : user;
10
+ //#endregion
11
+ export { actingUser };
@@ -0,0 +1,33 @@
1
+ import { authMessage } from "../auth/auth-messages.js";
2
+ import { useAuth } from "../auth/auth-provider.js";
3
+ import { useToast } from "../toasts/toast-provider.js";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { AuthError } from "@protobase/client";
6
+ //#region src/roles/use-active-role.ts
7
+ const failureMessage = (failure, fallback) => failure instanceof AuthError ? authMessage(failure) : fallback;
8
+ /** The role the session acts with; a failed read or switch shows as a toast. */
9
+ const useActiveRole = (reopen) => {
10
+ const { session } = useAuth();
11
+ const toast = useToast();
12
+ const [current, setCurrent] = useState();
13
+ useEffect(() => {
14
+ session.roles.current().then(setCurrent, (failure) => toast.show({
15
+ state: "error",
16
+ title: failureMessage(failure, "Could not read your roles")
17
+ }));
18
+ }, [session, toast]);
19
+ const switchTo = useCallback((name) => void session.roles.switchTo(name).then(reopen, (failure) => toast.show({
20
+ state: "error",
21
+ title: failureMessage(failure, "Could not switch the role")
22
+ })), [
23
+ session,
24
+ toast,
25
+ reopen
26
+ ]);
27
+ return {
28
+ ...current && { current },
29
+ switchTo
30
+ };
31
+ };
32
+ //#endregion
33
+ export { useActiveRole };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/ui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The Protobase admin app and the React hooks and components for custom UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -34,9 +34,9 @@
34
34
  "dependencies": {
35
35
  "@dnd-kit/core": "^6.3.1",
36
36
  "@dnd-kit/sortable": "^10.0.0",
37
- "@protobase/client": "0.9.0",
38
- "@protobase/layout": "0.9.0",
39
- "@protobase/schema": "0.9.0",
37
+ "@protobase/client": "0.11.0",
38
+ "@protobase/layout": "0.11.0",
39
+ "@protobase/schema": "0.11.0",
40
40
  "@tanstack/react-query": "^5.104.1",
41
41
  "@tanstack/react-table": "^9.2.6",
42
42
  "@tanstack/react-virtual": "^3.14.13",