@protobase/ui 0.14.4 → 0.14.5

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 (55) hide show
  1. package/dist/activity-chart/activity-chart.js +1 -1
  2. package/dist/app/app.d.ts +2 -1
  3. package/dist/app/app.js +10 -3
  4. package/dist/app/displays/display-controls.js +1 -1
  5. package/dist/app/embedded-list/embedded-list.js +35 -27
  6. package/dist/app/index.html +10 -0
  7. package/dist/app/list-page.js +20 -3
  8. package/dist/app/menu-icons.js +2 -1
  9. package/dist/app/named-icon.js +3 -2
  10. package/dist/app/pages/blocks/field.js +2 -2
  11. package/dist/app/pages/blocks/progress.js +1 -1
  12. package/dist/app/pages/blocks/show.js +1 -1
  13. package/dist/app/pages/blocks/stat.js +2 -2
  14. package/dist/app/pages/blocks/table.js +1 -1
  15. package/dist/app/searches/save-search-button.js +44 -0
  16. package/dist/app/searches/saved-search-section.js +97 -0
  17. package/dist/app/searches/saved-search.js +94 -0
  18. package/dist/app/searches/saved-searches.js +107 -0
  19. package/dist/app/searches/search-controls.js +64 -0
  20. package/dist/app/searches/use-filter-chips.js +27 -0
  21. package/dist/app/searches/use-save-search.js +70 -0
  22. package/dist/app/searches/use-saved-searches.js +37 -0
  23. package/dist/app-shell/app-shell.js +3 -2
  24. package/dist/app-shell/drawer.js +1 -1
  25. package/dist/app-shell/logo.js +2 -2
  26. package/dist/app-shell/profile-menu.js +10 -2
  27. package/dist/app-shell/sidebar.js +3 -2
  28. package/dist/app-shell/theme-switch.js +41 -0
  29. package/dist/auth/auth-gate.js +0 -9
  30. package/dist/data-grid/data-grid.js +1 -1
  31. package/dist/live/use-filter-config.js +2 -6
  32. package/dist/live/view-fields.js +8 -1
  33. package/dist/package.js +1 -1
  34. package/dist/primitives/dialog.js +1 -1
  35. package/dist/primitives/responsive-dialog.d.ts +1 -2
  36. package/dist/primitives/responsive-dialog.js +11 -13
  37. package/dist/primitives/segmented.js +6 -2
  38. package/dist/primitives/sheet.js +39 -39
  39. package/dist/shell/assets/app-DxpTYgsV.js +66 -0
  40. package/dist/shell/assets/index-B0V1UXFn.css +2 -0
  41. package/dist/shell/assets/{index-DhgTXlZ3.js → index-C_ilu7LX.js} +1 -1
  42. package/dist/shell/assets/{maplibre-gl-CKQN3M1I.js → maplibre-gl-DPsuz0sk.js} +1 -1
  43. package/dist/shell/assets/{record-map-CzJ60-ux.js → record-map-grRO6rSC.js} +2 -2
  44. package/dist/shell/assets/{records-map-dch_o1ug.js → records-map-DjUTonTE.js} +1 -1
  45. package/dist/shell/assets/{shared-protobase-ui-C-qDEeIK.js → shared-protobase-ui-BkgB1XZX.js} +1 -1
  46. package/dist/shell/index.html +14 -4
  47. package/dist/shell/protobase.shell.json +1 -1
  48. package/dist/styles.css +5 -2
  49. package/dist/theme/theme-preference.js +23 -0
  50. package/dist/theme/theme-provider.js +43 -0
  51. package/dist/theme.css +4 -0
  52. package/dist/tokens.css +2 -0
  53. package/package.json +4 -5
  54. package/dist/shell/assets/app-CyW0VzOi.js +0 -66
  55. package/dist/shell/assets/index-DthLyPYI.css +0 -2
@@ -0,0 +1,107 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { PageHeader } from "../../app-shell/page-header.js";
3
+ import { recordKey, recordTitleField } from "../../live/model-helpers.js";
4
+ import { humanize } from "../../live/naming.js";
5
+ import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
6
+ import { AppLink } from "../use-app-address.js";
7
+ import { ErrorBanner } from "../error-banner.js";
8
+ import { can } from "../permissions.js";
9
+ import { useDeleteFlow } from "../delete/use-delete-flow.js";
10
+ import { useSavedSearches } from "./use-saved-searches.js";
11
+ import { SavedSearchSection } from "./saved-search-section.js";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ import { encodeKey } from "@protobase/schema/internal";
14
+ //#region src/app/searches/saved-searches.tsx
15
+ /**
16
+ * The list page of a view with `searchesOf`: each of the user's saved searches, with what it finds (`SavedSearchSection`),
17
+ * in the order of the view's list; until they have loaded, or when there are none, the page says so under its title.
18
+ */
19
+ const SavedSearches = ({ model, view }) => {
20
+ const { resources, views } = useAdminMeta();
21
+ const of = view.searchesOf;
22
+ const target = resources[of.resource];
23
+ const targetView = views[of.resource];
24
+ const saved = useSavedSearches(model.name, true);
25
+ const permissions = usePermissions(model.name);
26
+ const remove = useDeleteFlow({
27
+ model,
28
+ view
29
+ });
30
+ const plural = view.names?.plural ?? humanize(model.name);
31
+ const titleField = recordTitleField(model, view);
32
+ const frame = (body) => /* @__PURE__ */ jsxs("div", {
33
+ className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
34
+ children: [
35
+ remove.dialogs,
36
+ /* @__PURE__ */ jsx(PageHeader, {
37
+ title: plural,
38
+ subtitle: view.help
39
+ }),
40
+ body
41
+ ]
42
+ });
43
+ if (!target) return frame(/* @__PURE__ */ jsxs(Notice, {
44
+ title: "Not found",
45
+ children: [
46
+ "There is no resource called ",
47
+ of.resource,
48
+ "."
49
+ ]
50
+ }));
51
+ if (saved.error) return frame(/* @__PURE__ */ jsx(ErrorBanner, {
52
+ title: `Could not load your ${plural.toLowerCase()}`,
53
+ error: saved.error
54
+ }));
55
+ if (!saved.data) return frame(/* @__PURE__ */ jsx("div", {
56
+ className: "flex flex-col gap-3",
57
+ children: Array.from({ length: 2 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-56" }, index))
58
+ }));
59
+ if (saved.data.length === 0) {
60
+ const searched = (targetView?.names?.plural ?? humanize(target.name)).toLowerCase();
61
+ return frame(/* @__PURE__ */ jsxs("p", {
62
+ className: "py-8 text-center text-[13px] text-muted-foreground",
63
+ children: [
64
+ "No ",
65
+ plural.toLowerCase(),
66
+ " yet. Filter the",
67
+ " ",
68
+ /* @__PURE__ */ jsx(AppLink, {
69
+ to: {
70
+ kind: "list",
71
+ resource: target.name
72
+ },
73
+ className: "font-medium text-primary-text hover:underline",
74
+ children: searched
75
+ }),
76
+ " ",
77
+ "and save the search."
78
+ ]
79
+ }));
80
+ }
81
+ return frame(/* @__PURE__ */ jsx("div", {
82
+ className: "flex flex-col gap-4",
83
+ children: saved.data.map((row) => {
84
+ const key = encodeKey(recordKey(model, row));
85
+ const title = String(row[titleField] ?? key);
86
+ const onDelete = can(permissions, "delete") ? () => void remove.start({
87
+ key,
88
+ title,
89
+ etag: row.etag,
90
+ snapshot: row
91
+ }) : void 0;
92
+ return /* @__PURE__ */ jsx(SavedSearchSection, {
93
+ model,
94
+ view,
95
+ of,
96
+ target,
97
+ targetView,
98
+ row,
99
+ recordKey: key,
100
+ title,
101
+ onDelete
102
+ }, key);
103
+ })
104
+ }));
105
+ };
106
+ //#endregion
107
+ export { SavedSearches };
@@ -0,0 +1,64 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { Switch } from "../../primitives/switch.js";
5
+ import { useRefreshResource } from "../pages/use-layout-data.js";
6
+ import { useId } from "react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { useMutation } from "@tanstack/react-query";
9
+ import { enumLabel } from "@protobase/schema";
10
+ //#region src/app/searches/search-controls.tsx
11
+ const message = (error) => error instanceof Error ? error.message : "Something went wrong";
12
+ const Control = ({ model, view, name, value, onChange }) => {
13
+ const id = useId();
14
+ const field = model.fields[name];
15
+ const hints = view.fields[name];
16
+ const label = hints?.label ?? humanize(name);
17
+ if (field?.type === "boolean") return /* @__PURE__ */ jsx(Switch, {
18
+ checked: value === true,
19
+ onChange,
20
+ label
21
+ });
22
+ if (field?.type !== "enum") throw new Error(`Not implemented: "${name}" is ${field?.type ?? "unknown"}, and a saved search's controls are enums or booleans`);
23
+ return /* @__PURE__ */ jsxs("label", {
24
+ htmlFor: id,
25
+ className: "flex items-center gap-2 text-[13px] text-muted-foreground",
26
+ children: [label, /* @__PURE__ */ jsx("select", {
27
+ id,
28
+ value: String(value ?? ""),
29
+ onChange: (event) => onChange(event.target.value),
30
+ className: "h-8 rounded-md border border-border-strong bg-background px-2 text-[13px] text-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
31
+ children: (field.enumValues ?? []).map((option) => /* @__PURE__ */ jsx("option", {
32
+ value: option,
33
+ children: enumLabel(option, hints?.valueLabels)
34
+ }, option))
35
+ })]
36
+ });
37
+ };
38
+ /** A saved search's controls, each saved as soon as it changes; a refused change says why and shows the search as stored. */
39
+ const SearchControls = ({ model, view, recordKey, row, fields }) => {
40
+ const client = useClient();
41
+ const toast = useToast();
42
+ const refresh = useRefreshResource();
43
+ const change = useMutation({
44
+ mutationFn: (patch) => client.update(model.name, recordKey, patch, row.etag ?? "*"),
45
+ onError: (error) => toast.show({
46
+ state: "error",
47
+ title: "Could not change the search",
48
+ description: message(error)
49
+ }),
50
+ onSettled: () => refresh(model.name)
51
+ });
52
+ return /* @__PURE__ */ jsx("div", {
53
+ className: "flex flex-wrap items-center gap-3",
54
+ children: fields.map((name) => /* @__PURE__ */ jsx(Control, {
55
+ model,
56
+ view,
57
+ name,
58
+ value: change.isPending && name in (change.variables ?? {}) ? change.variables[name] : row[name],
59
+ onChange: (value) => change.mutate({ [name]: value })
60
+ }, name))
61
+ });
62
+ };
63
+ //#endregion
64
+ export { SearchControls };
@@ -0,0 +1,27 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { fetchLabels } from "../../live/relation-labels.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { chosenRelations, filterChips } from "./saved-search.js";
6
+ import { useQueries, useQueryClient } from "@tanstack/react-query";
7
+ //#region src/app/searches/use-filter-chips.ts
8
+ /** A saved filter of `model`'s list as chips, with the records its relation facets choose named once they have loaded. */
9
+ const useFilterChips = (model, text) => {
10
+ const client = useClient();
11
+ const queryClient = useQueryClient();
12
+ const { resources, views } = useAdminMeta();
13
+ const view = views[model.name];
14
+ const chosen = Object.entries(chosenRelations(model, view, text));
15
+ const named = useQueries({ queries: chosen.map(([field, ids]) => {
16
+ const target = resources[model.fields[field].relation.resource];
17
+ return {
18
+ queryKey: keys.labels(`chips:${model.name}.${field}`, ids.join(",")),
19
+ enabled: target !== void 0,
20
+ queryFn: async () => Object.fromEntries(await fetchLabels(queryClient, client, target, ids, views[target.name]))
21
+ };
22
+ }) });
23
+ const names = Object.fromEntries(chosen.flatMap(([field], index) => named[index]?.data ? [[field, named[index].data]] : []));
24
+ return filterChips(model, view, text, names);
25
+ };
26
+ //#endregion
27
+ export { useFilterChips };
@@ -0,0 +1,70 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { useAppAddress } from "../use-app-address.js";
6
+ import { can } from "../permissions.js";
7
+ import { useRefreshResource } from "../pages/use-layout-data.js";
8
+ import { useSavedSearches } from "./use-saved-searches.js";
9
+ import { useMutation } from "@tanstack/react-query";
10
+ import { encodeKey } from "@protobase/schema/internal";
11
+ //#region src/app/searches/use-save-search.ts
12
+ const message = (error) => error instanceof Error ? error.message : "Something went wrong";
13
+ /**
14
+ * A list page's "Save search" button for its `filter`: `save` for someone who can list, create and delete the saved
15
+ * searches, `sign-in` for a visitor their resource asks to sign in (`prompt`), nothing otherwise. A press saves the
16
+ * filter as a search named `name`, or takes the saved one off again.
17
+ */
18
+ const useSaveSearch = (search, filter, name) => {
19
+ const meta = useAdminMeta();
20
+ const client = useClient();
21
+ const toast = useToast();
22
+ const refresh = useRefreshResource();
23
+ const { go } = useAppAddress();
24
+ const { resource } = search;
25
+ const model = meta.resources[resource];
26
+ const of = meta.views[resource]?.searchesOf;
27
+ const permissions = meta.permissions[resource];
28
+ const usable = model !== void 0 && of !== void 0 && permissions !== void 0 && can(permissions, "create") && can(permissions, "delete");
29
+ const prompt = meta.signIn?.[resource]?.signIn;
30
+ const saved = useSavedSearches(resource, usable);
31
+ const current = of && saved.data?.find((row) => row[of.filter] === filter);
32
+ const plural = meta.views[resource]?.names?.plural ?? "saved searches";
33
+ const toggle = useMutation({
34
+ mutationFn: async (existing) => {
35
+ if (existing) await client.remove(resource, encodeKey(recordKey(model, existing)));
36
+ else await client.create(resource, {
37
+ [of.filter]: filter,
38
+ ...of.name && { [of.name]: name }
39
+ });
40
+ },
41
+ onSuccess: (_, existing) => {
42
+ if (!existing) toast.show({
43
+ state: "success",
44
+ title: "Search saved",
45
+ action: {
46
+ label: `Open ${plural.toLowerCase()}`,
47
+ onClick: () => go({
48
+ kind: "list",
49
+ resource
50
+ })
51
+ }
52
+ });
53
+ },
54
+ onError: (error, existing) => toast.show({
55
+ state: "error",
56
+ title: existing ? "Could not take the search off" : "Could not save the search",
57
+ description: message(error)
58
+ }),
59
+ onSettled: () => refresh(resource)
60
+ });
61
+ return {
62
+ mode: usable ? "save" : prompt ? "sign-in" : void 0,
63
+ prompt,
64
+ saved: current !== void 0,
65
+ ready: saved.data !== void 0 && !toggle.isPending,
66
+ toggle: () => toggle.mutate(current)
67
+ };
68
+ };
69
+ //#endregion
70
+ export { useSaveSearch };
@@ -0,0 +1,37 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { defaultOrderBy } from "../list-order.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ //#region src/app/searches/use-saved-searches.ts
7
+ /** Where the user's saved searches of `resource` are cached; a list key, so a write to the resource refreshes it. */
8
+ const savedSearchesKey = (resource) => keys.list(resource, "", "", 500, "saved-searches");
9
+ /**
10
+ * The signed-in user's saved searches, in the order of their view's list: every one, page by page, as the API lists only
11
+ * a user's own records of a resource with an owner. Loads only while `enabled`.
12
+ */
13
+ const useSavedSearches = (resource, enabled) => {
14
+ const client = useClient();
15
+ const { resources, views } = useAdminMeta();
16
+ const model = resources[resource];
17
+ return useQuery({
18
+ queryKey: savedSearchesKey(resource),
19
+ enabled: enabled && model !== void 0,
20
+ queryFn: async () => {
21
+ const rows = [];
22
+ let pageToken;
23
+ do {
24
+ const page = await client.list(resource, {
25
+ pageSize: 500,
26
+ orderBy: defaultOrderBy(model, views[resource]),
27
+ ...pageToken && { pageToken }
28
+ });
29
+ rows.push(...page.items);
30
+ pageToken = page.nextPageToken || void 0;
31
+ } while (pageToken);
32
+ return rows;
33
+ }
34
+ });
35
+ };
36
+ //#endregion
37
+ export { savedSearchesKey, useSavedSearches };
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from "react";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
10
  //#region src/app-shell/app-shell.tsx
11
11
  const drawerId = "app-drawer";
12
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, onSignIn, organizations, roles, search, className, children }) => {
12
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, onSignIn, organizations, roles, theme, search, className, children }) => {
13
13
  const desktop = useIsDesktop();
14
14
  const [drawerOpen, setDrawerOpen] = useState(false);
15
15
  useEffect(() => {
@@ -34,7 +34,8 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
34
34
  onSignOut,
35
35
  onSignIn,
36
36
  organizations,
37
- roles
37
+ roles,
38
+ theme
38
39
  });
39
40
  return /* @__PURE__ */ jsx(PageScrollContext.Provider, {
40
41
  value: setPageScroll,
@@ -10,7 +10,7 @@ const Drawer = ({ id, open, onClose, children }) => {
10
10
  return /* @__PURE__ */ jsxs("div", {
11
11
  className: "fixed inset-0 z-50 md:hidden",
12
12
  children: [/* @__PURE__ */ jsx("div", {
13
- className: "absolute inset-0 bg-foreground/40",
13
+ className: "absolute inset-0 bg-backdrop/40",
14
14
  onClick: onClose,
15
15
  "aria-hidden": true
16
16
  }), /* @__PURE__ */ jsx("div", {
@@ -15,14 +15,14 @@ const LogoMark = ({ className }) => /* @__PURE__ */ jsxs("svg", {
15
15
  /* @__PURE__ */ jsx("path", {
16
16
  d: "M7 17V7h5.2a3 3 0 0 1 0 6H7",
17
17
  fill: "none",
18
- stroke: "white",
18
+ className: "stroke-primary-foreground",
19
19
  strokeWidth: "2",
20
20
  strokeLinecap: "round",
21
21
  strokeLinejoin: "round"
22
22
  }),
23
23
  /* @__PURE__ */ jsx("path", {
24
24
  d: "M12.5 17h4.5",
25
- stroke: "white",
25
+ className: "stroke-primary-foreground",
26
26
  strokeOpacity: "0.55",
27
27
  strokeWidth: "2",
28
28
  strokeLinecap: "round"
@@ -2,13 +2,14 @@ import { cn } from "../lib/cn.js";
2
2
  import { Avatar } from "../primitives/avatar.js";
3
3
  import { Badge } from "../primitives/badge.js";
4
4
  import { SwitchGroup } from "./switch-group.js";
5
+ import { ThemeSwitch } from "./theme-switch.js";
5
6
  import { useEffect, useRef, useState } from "react";
6
7
  import { ChevronsUpDown, LogOut } from "lucide-react";
7
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
9
  //#region src/app-shell/profile-menu.tsx
9
10
  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";
10
11
  /** The person at the bottom of the sidebar; the menu is fixed to the viewport so a narrow sidebar cannot clip it. */
11
- const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations, roles }) => {
12
+ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations, roles, theme }) => {
12
13
  const activeRole = roles?.roles.find((role) => role.name === roles.active);
13
14
  const [local, setLocal] = useState(false);
14
15
  const open = controlled ?? local;
@@ -112,7 +113,7 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
112
113
  }
113
114
  }),
114
115
  items.length > 0 && /* @__PURE__ */ jsx("div", {
115
- className: cn(onSignOut && "border-b"),
116
+ className: cn((theme || onSignOut) && "border-b"),
116
117
  children: items.map((item) => {
117
118
  const Icon = item.icon;
118
119
  return /* @__PURE__ */ jsxs("a", {
@@ -134,6 +135,13 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
134
135
  }, item.id);
135
136
  })
136
137
  }),
138
+ theme && /* @__PURE__ */ jsx("div", {
139
+ className: cn(onSignOut && "border-b"),
140
+ children: /* @__PURE__ */ jsx(ThemeSwitch, {
141
+ theme,
142
+ className: cn(menuRow, "hover:bg-transparent")
143
+ })
144
+ }),
137
145
  onSignOut && /* @__PURE__ */ jsxs("button", {
138
146
  type: "button",
139
147
  role: "menuitem",
@@ -8,7 +8,7 @@ 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, sticky }) => {
11
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles, theme, sticky }) => {
12
12
  const expanding = mode === "icon-expand";
13
13
  const expanded = expanding && forceExpanded;
14
14
  const inline = isTextMode(mode) || expanding;
@@ -56,7 +56,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
56
56
  onNavigate,
57
57
  onSignOut,
58
58
  organizations,
59
- roles
59
+ roles,
60
+ theme
60
61
  }) : onSignIn && /* @__PURE__ */ jsxs("button", {
61
62
  type: "button",
62
63
  onClick: onSignIn,
@@ -0,0 +1,41 @@
1
+ import { Segmented } from "../primitives/segmented.js";
2
+ import { Monitor, Moon, Sun, SunMoon } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app-shell/theme-switch.tsx
5
+ const options = [
6
+ {
7
+ value: "light",
8
+ label: "Light",
9
+ icon: Sun
10
+ },
11
+ {
12
+ value: "dark",
13
+ label: "Dark",
14
+ icon: Moon
15
+ },
16
+ {
17
+ value: "system",
18
+ label: "System",
19
+ icon: Monitor
20
+ }
21
+ ];
22
+ /** The profile menu's row that picks light, dark or the system's theme; the menu stays open to show the result. */
23
+ const ThemeSwitch = ({ theme, className }) => /* @__PURE__ */ jsxs("div", {
24
+ className,
25
+ children: [
26
+ /* @__PURE__ */ jsx(SunMoon, { className: "size-4" }),
27
+ /* @__PURE__ */ jsx("span", {
28
+ className: "flex-1",
29
+ children: "Theme"
30
+ }),
31
+ /* @__PURE__ */ jsx(Segmented, {
32
+ label: "Theme",
33
+ iconOnly: true,
34
+ value: theme.preference,
35
+ options,
36
+ onChange: theme.onChange
37
+ })
38
+ ]
39
+ });
40
+ //#endregion
41
+ export { ThemeSwitch };
@@ -69,15 +69,6 @@ const AuthGate = ({ workspace, children }) => {
69
69
  useEffect(() => {
70
70
  if (state.kind === "signed-out" && page.kind === "sign-in" && page.providerError) forgetSignInLink();
71
71
  }, [state.kind, page]);
72
- useEffect(() => {
73
- if (state.kind !== "signed-out" || !browse) return;
74
- const leave = () => {
75
- setPage({ kind: "sign-in" });
76
- browse();
77
- };
78
- window.addEventListener("popstate", leave);
79
- return () => window.removeEventListener("popstate", leave);
80
- }, [state.kind, browse]);
81
72
  const signInFailed = useCallback((error) => setPage({
82
73
  kind: "sign-in",
83
74
  error
@@ -203,7 +203,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
203
203
  children: [header, body]
204
204
  }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [header, body] }), horizontal.more && /* @__PURE__ */ jsx("div", {
205
205
  "aria-hidden": true,
206
- className: "pointer-events-none absolute inset-y-0 right-0 z-20 w-8 bg-gradient-to-l from-foreground/15 to-transparent"
206
+ className: "pointer-events-none absolute inset-y-0 right-0 z-20 w-8 bg-gradient-to-l from-backdrop/15 to-transparent"
207
207
  })]
208
208
  }), !fit && /* @__PURE__ */ jsx(GridFooter, {
209
209
  total,
@@ -1,4 +1,3 @@
1
- import { formatMoney } from "../format/money.js";
2
1
  import { keys } from "../data/query-keys.js";
3
2
  import { useClient } from "../data/api-provider.js";
4
3
  import { labelFieldOf } from "./model-helpers.js";
@@ -8,7 +7,7 @@ import { setRange } from "../filter-panel/filter-state.js";
8
7
  import { stateToText } from "./filter-string.js";
9
8
  import { labelKeys, matchOptions } from "./reference-options.js";
10
9
  import { useReferenceSearch } from "./use-reference-search.js";
11
- import { rangeHistogram, widgetFields } from "./view-fields.js";
10
+ import { rangeFormat, rangeHistogram, widgetFields } from "./view-fields.js";
12
11
  import { useQueries, useQueryClient } from "@tanstack/react-query";
13
12
  import { enumLabel } from "@protobase/schema";
14
13
  //#region src/live/use-filter-config.ts
@@ -107,9 +106,6 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
107
106
  const rangeConfig = (range, index) => {
108
107
  const data = histogramQueries[index]?.data;
109
108
  if (!data || data.min === null || data.max === null) return [];
110
- const hints = view?.fields[range.id];
111
- const prefix = hints?.prefix;
112
- const suffix = hints?.suffix ?? "";
113
109
  return [{
114
110
  ...range,
115
111
  label: label(range.id),
@@ -117,7 +113,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
117
113
  max: Math.ceil(data.max),
118
114
  step: Math.max(1, Math.round((data.max - data.min) / 100)),
119
115
  histogram: rangeHistogram(view, range.id) ? data.buckets.map((bucket) => bucket.count) : [],
120
- format: (value) => `${prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)}${suffix}`
116
+ format: rangeFormat(view, range.id)
121
117
  }];
122
118
  };
123
119
  return {
@@ -1,3 +1,4 @@
1
+ import { formatMoney } from "../format/money.js";
1
2
  //#region src/live/view-fields.ts
2
3
  const numberTypes = [
3
4
  "integer",
@@ -61,5 +62,11 @@ const widgetFields = (view, model) => {
61
62
  };
62
63
  /** Whether a range draws its histogram: only a `range` widget that asks for one. */
63
64
  const rangeHistogram = (view, field) => (view?.filters ?? []).some((widget) => widget.kind === "range" && widget.field === field && widget.histogram);
65
+ /** How a range's values read, by the view's prefix and suffix: "€450,000" for a euro amount, "85 m²". */
66
+ const rangeFormat = (view, field) => {
67
+ const hints = view?.fields[field];
68
+ const suffix = hints?.suffix ?? "";
69
+ return (value) => `${hints?.prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)}${suffix}`;
70
+ };
64
71
  //#endregion
65
- export { rangeHistogram, widgetFields };
72
+ export { rangeFormat, rangeHistogram, widgetFields };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.14.4";
2
+ var version = "0.14.5";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -7,7 +7,7 @@ const Dialog = ({ title, description, onClose, actions, children }) => {
7
7
  const panel = useRef(null);
8
8
  useFocusTrap(panel, true, onClose);
9
9
  return /* @__PURE__ */ jsx("div", {
10
- className: "fixed inset-0 z-[90] flex items-center justify-center bg-foreground/30 p-4",
10
+ className: "fixed inset-0 z-[90] flex items-center justify-center bg-backdrop/30 p-4",
11
11
  onMouseDown: (event) => event.target === event.currentTarget && onClose(),
12
12
  children: /* @__PURE__ */ jsxs("div", {
13
13
  ref: panel,
@@ -11,8 +11,7 @@ export type ResponsiveDialogProps = {
11
11
  };
12
12
  /**
13
13
  * A modal centred on the screen from the `md` breakpoint up, and a sheet rising from the bottom on a phone, switching as
14
- * the viewport does. Both close on Escape and on a press outside, and keep focus inside while open; the sheet also
15
- * closes when swiped down.
14
+ * the viewport does. Both close on Escape and on a press outside, and keep focus inside while open.
16
15
  */
17
16
  export declare const ResponsiveDialog: ({ title, description, open, onClose, actions, children }: ResponsiveDialogProps) => import("react").JSX.Element | null;
18
17
  //#endregion
@@ -5,23 +5,21 @@ import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/primitives/responsive-dialog.tsx
6
6
  /**
7
7
  * A modal centred on the screen from the `md` breakpoint up, and a sheet rising from the bottom on a phone, switching as
8
- * the viewport does. Both close on Escape and on a press outside, and keep focus inside while open; the sheet also
9
- * closes when swiped down.
8
+ * the viewport does. Both close on Escape and on a press outside, and keep focus inside while open.
10
9
  */
11
10
  const ResponsiveDialog = ({ title, description, open, onClose, actions, children }) => {
12
- if (useIsDesktop()) {
13
- if (!open) return null;
14
- return /* @__PURE__ */ jsx(Dialog, {
15
- title,
16
- description,
17
- onClose,
18
- actions,
19
- children
20
- });
21
- }
11
+ const desktop = useIsDesktop();
12
+ if (!open) return null;
13
+ if (desktop) return /* @__PURE__ */ jsx(Dialog, {
14
+ title,
15
+ description,
16
+ onClose,
17
+ actions,
18
+ children
19
+ });
22
20
  return /* @__PURE__ */ jsx(Sheet, {
23
21
  title,
24
- open,
22
+ open: true,
25
23
  onClose,
26
24
  children: /* @__PURE__ */ jsxs("div", {
27
25
  className: "pt-3 text-[13px]",
@@ -11,16 +11,20 @@ const sizes = {
11
11
  option: "px-3 text-[13px]"
12
12
  }
13
13
  };
14
- const Segmented = ({ value, options, onChange, label, size = "sm" }) => /* @__PURE__ */ jsx("span", {
14
+ const Segmented = ({ value, options, onChange, label, size = "sm", iconOnly = false }) => /* @__PURE__ */ jsx("span", {
15
15
  role: "group",
16
16
  "aria-label": label,
17
17
  className: cn("inline-flex", sizes[size].group, "rounded-md bg-muted p-0.5"),
18
18
  children: options.map((option) => /* @__PURE__ */ jsxs("button", {
19
19
  type: "button",
20
20
  "aria-pressed": option.value === value,
21
+ ...iconOnly && {
22
+ "aria-label": option.label,
23
+ title: option.label
24
+ },
21
25
  onClick: () => onChange(option.value),
22
26
  className: cn("inline-flex items-center gap-1.5 rounded", sizes[size].option, "font-medium transition-colors", option.value === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
23
- children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), option.label]
27
+ children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), !iconOnly && option.label]
24
28
  }, option.value))
25
29
  });
26
30
  //#endregion