@protobase/ui 0.22.4 → 0.22.6

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 (31) hide show
  1. package/dist/app/app.js +11 -6
  2. package/dist/app/breadcrumb-for.js +2 -1
  3. package/dist/app/current-address.d.ts +2 -1
  4. package/dist/app/current-address.js +7 -5
  5. package/dist/app/displays/map-display.js +1 -0
  6. package/dist/app/displays/use-map-records.js +2 -2
  7. package/dist/app/list-page.js +14 -5
  8. package/dist/app/nav-from-meta.js +31 -12
  9. package/dist/app/use-app-address.js +3 -3
  10. package/dist/filter-panel/filter-menu.js +1 -1
  11. package/dist/filter-panel/filter-panel.js +23 -8
  12. package/dist/filter-panel/more-filters-menu.js +54 -0
  13. package/dist/filter-panel/secondary-filters.js +46 -0
  14. package/dist/live/use-filter-config.js +2 -1
  15. package/dist/live/view-fields.js +14 -8
  16. package/dist/package.js +1 -1
  17. package/dist/primitives/menu-item.js +4 -0
  18. package/dist/record-view/action-menu.js +3 -3
  19. package/dist/shell/assets/app-Bp_H43C6.js +69 -0
  20. package/dist/shell/assets/index-BtU8c1DC.css +2 -0
  21. package/dist/shell/assets/{index-DylcQP59.js → index-q2Lohp8c.js} +1 -1
  22. package/dist/shell/assets/{maplibre-CY0vkBw4.js → maplibre-L-NOV9Ww.js} +1 -1
  23. package/dist/shell/assets/{markdown-content-D3r5sNwd.js → markdown-content-CM_VWSfv.js} +1 -1
  24. package/dist/shell/assets/{record-map-C9XvJmY1.js → record-map-CElcOkuO.js} +1 -1
  25. package/dist/shell/assets/{records-map-BtQTl7lD.js → records-map-pk9uGJBc.js} +1 -1
  26. package/dist/shell/assets/{shared-protobase-ui-BIRhZOyK.js → shared-protobase-ui-DvT94uLD.js} +1 -1
  27. package/dist/shell/index.html +4 -4
  28. package/dist/shell/protobase.shell.json +1 -1
  29. package/package.json +4 -4
  30. package/dist/shell/assets/app-BM5iQ8yZ.js +0 -69
  31. package/dist/shell/assets/index-DhW8t8wq.css +0 -2
package/dist/app/app.js CHANGED
@@ -29,6 +29,8 @@ import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
29
29
  import { breadcrumbFor } from "./breadcrumb-for.js";
30
30
  import { ComposedPage } from "./pages/composed-page.js";
31
31
  import { CreateRoute } from "./create/create-route.js";
32
+ import { userMenuFromMeta } from "./user-menu-from-meta.js";
33
+ import { navFromMeta, navItemId } from "./nav-from-meta.js";
32
34
  import { ListPage } from "./list-page.js";
33
35
  import { MembersPage } from "../organizations/members-page.js";
34
36
  import { InvitationsPage } from "../organizations/invitations-page.js";
@@ -37,8 +39,6 @@ import { OrganizationPage } from "../organizations/organization-page.js";
37
39
  import { OrganizationsPage } from "../organizations/organizations-page.js";
38
40
  import { actingUser } from "../roles/acting-user.js";
39
41
  import { useActiveRole } from "../roles/use-active-role.js";
40
- import { userMenuFromMeta } from "./user-menu-from-meta.js";
41
- import { navFromMeta } from "./nav-from-meta.js";
42
42
  import { ProjectLogo } from "./project-logo.js";
43
43
  import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
44
44
  import { PluginNotice } from "../plugins/plugin-notice.js";
@@ -50,8 +50,13 @@ import { StaffBanner } from "./staff-banner.js";
50
50
  import { useNavRecent } from "./use-nav-recent.js";
51
51
  import { useCallback, useEffect, useState } from "react";
52
52
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
53
+ import { viewOfList } from "@protobase/schema/internal";
53
54
  //#region src/app/app.tsx
54
- const placeOf = (address) => address?.kind === "page" ? address.page : address?.resource;
55
+ const placeOf = (address) => {
56
+ if (address?.kind === "page") return address.page;
57
+ if (address?.kind === "list") return navItemId(address.resource, address.list);
58
+ return address?.resource;
59
+ };
55
60
  const recordOf = (address) => address?.kind === "record" ? address : void 0;
56
61
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
57
62
  const meta = useAdminMeta();
@@ -67,7 +72,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
67
72
  const search = useGlobalSearch();
68
73
  const theme = useTheme();
69
74
  const recent = useNavRecent(meta, basePath, {
70
- resource: place,
75
+ resource: shown?.resource,
71
76
  key: shown?.key
72
77
  });
73
78
  const groups = navFromMeta(meta, basePath, recent);
@@ -92,7 +97,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
92
97
  ]);
93
98
  const resource = address?.kind === "page" ? void 0 : address?.resource;
94
99
  const drawnFrom = resource ? sourceStatusOf(meta, resource) : void 0;
95
- const view = resource ? meta.views[resource] : void 0;
100
+ const view = resource ? viewOfList(meta.views[resource], address?.kind === "list" ? address.list : void 0) : void 0;
96
101
  const group = groups.find((entry) => entry.items.some((item) => item.id === place));
97
102
  const model = resource ? meta.resources[resource] : void 0;
98
103
  const record = useOpenedRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
@@ -183,7 +188,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
183
188
  }, address.resource) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
184
189
  resource: address.resource,
185
190
  recordKey: address.key
186
- }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
191
+ }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, navItemId(address.resource, address.list));
187
192
  const appShell = /* @__PURE__ */ jsx(AppShell, {
188
193
  pageScroll: true,
189
194
  sidebarMode: sidebarMode ?? "text-small",
@@ -15,7 +15,8 @@ const breadcrumbFor = ({ basePath, address, group, page, resourceLabel, recordTi
15
15
  label: resourceLabel,
16
16
  href: appPath({
17
17
  kind: "list",
18
- resource: address.resource
18
+ resource: address.resource,
19
+ ...address.kind === "list" && address.list && { list: address.list }
19
20
  }, basePath)
20
21
  }] : [];
21
22
  if (address.kind === "list") return [...groupCrumb, ...resource];
@@ -7,6 +7,7 @@ import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, ViewMod
7
7
  export type ListAddress = {
8
8
  kind: 'list';
9
9
  resource: string;
10
+ list?: string;
10
11
  display: string;
11
12
  filter: string;
12
13
  orderBy: string;
@@ -31,5 +32,5 @@ export type CurrentAddress = ListAddress | {
31
32
  page: string;
32
33
  };
33
34
  /** What can change about the list the app shows; `undefined` returns a value to its default. */
34
- export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource'>>;
35
+ export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource' | 'list'>>;
35
36
  //#endregion
@@ -1,14 +1,15 @@
1
1
  import { defaultDisplay, listDefaultDisplay } from "./displays/list-displays.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
- import { encodeKey } from "@protobase/schema/internal";
3
+ import { encodeKey, viewOfList } from "@protobase/schema/internal";
4
4
  //#region src/app/current-address.ts
5
5
  /**
6
6
  * The resource whose records a list shows, with its view: the list's own, or, when its view has `listOf`, the resource
7
7
  * that relation points at, if the caller can see it.
8
8
  */
9
- const listedResource = (resources, views, resource) => {
9
+ const listedResource = (resources, views, resource, list) => {
10
10
  const model = resources[resource];
11
- const listOf = views[resource]?.listOf;
11
+ const own = viewOfList(views[resource], list);
12
+ const listOf = own?.listOf;
12
13
  const target = listOf === void 0 ? void 0 : model?.fields[listOf]?.relation?.resource;
13
14
  return target !== void 0 && resources[target] ? {
14
15
  model: resources[target],
@@ -16,7 +17,7 @@ const listedResource = (resources, views, resource) => {
16
17
  listOf: true
17
18
  } : {
18
19
  model,
19
- view: views[resource],
20
+ view: own,
20
21
  listOf: false
21
22
  };
22
23
  };
@@ -38,7 +39,7 @@ const currentAddress = (read, defaults) => {
38
39
  };
39
40
  if (read?.kind !== "list") return read;
40
41
  return {
41
- ...defaults(read.resource),
42
+ ...defaults(read.resource, read.list),
42
43
  ...read
43
44
  };
44
45
  };
@@ -52,6 +53,7 @@ const changedList = (read, change, defaults) => {
52
53
  return {
53
54
  kind: "list",
54
55
  resource: read.resource,
56
+ ...read.list && { list: read.list },
55
57
  ...differs("filter") && { filter: next.filter },
56
58
  ...differs("display") && { display: next.display },
57
59
  ...differs("orderBy") && { orderBy: next.orderBy },
@@ -33,6 +33,7 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
33
33
  const [fitting, setFitting] = useState(at === void 0);
34
34
  const records = useMapRecords({
35
35
  model,
36
+ view,
36
37
  display,
37
38
  fields,
38
39
  filter,
@@ -12,7 +12,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
12
12
  * many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
13
13
  * left is never shown.
14
14
  */
15
- const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
15
+ const useMapRecords = ({ model, view, display, fields, filter, orderBy, area }) => {
16
16
  const client = useClient();
17
17
  const { resources, views } = useAdminMeta();
18
18
  const position = {
@@ -32,7 +32,7 @@ const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
32
32
  filter: inside,
33
33
  orderBy,
34
34
  labels: listLabels(resources, views, model, fields),
35
- photos: listPhotos(views[model.name])
35
+ photos: listPhotos(view)
36
36
  });
37
37
  return {
38
38
  rows: withLabels(resources, model, fields, withPhotos(model, result.items, result.photos), result.labels).map((record) => displayRow(model, record)),
@@ -50,6 +50,7 @@ import { useListScroll } from "./list-scroll.js";
50
50
  import { ListChart } from "./list-chart.js";
51
51
  import { ListGroupChart } from "./list-group-chart.js";
52
52
  import { ListSearch } from "./list-search.js";
53
+ import { navItemId } from "./nav-from-meta.js";
53
54
  import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
54
55
  import { SavedList } from "./saved/saved-list.js";
55
56
  import { SaveSearchButton } from "./searches/save-search-button.js";
@@ -62,7 +63,7 @@ import { useMemo, useState } from "react";
62
63
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
63
64
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
64
65
  import { createWhere } from "@protobase/schema";
65
- import { isGroupChart, tableDisplay } from "@protobase/schema/internal";
66
+ import { isGroupChart, tableDisplay, viewOfList } from "@protobase/schema/internal";
66
67
  //#region src/app/list-page.tsx
67
68
  const where$1 = createWhere();
68
69
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
@@ -86,12 +87,20 @@ const ListPage = ({ address }) => {
86
87
  "."
87
88
  ]
88
89
  });
89
- const view = views[address.resource];
90
+ const view = viewOfList(views[address.resource], address.list);
91
+ if (address.list !== void 0 && !view) return /* @__PURE__ */ jsxs(Notice, {
92
+ title: "Not found",
93
+ children: [
94
+ "There is no list called ",
95
+ address.list,
96
+ "."
97
+ ]
98
+ });
90
99
  if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearches, {
91
100
  model,
92
101
  view
93
102
  });
94
- const listed = listedResource(resources, views, address.resource);
103
+ const listed = listedResource(resources, views, address.resource, address.list);
95
104
  if (view && listed.listOf) {
96
105
  const target = listed.model;
97
106
  return /* @__PURE__ */ jsx(SavedList, {
@@ -118,7 +127,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
118
127
  const meta = useAdminMeta();
119
128
  const { set, listTo, go, href } = useAppAddress();
120
129
  const resource = model.name;
121
- const view = meta.views[resource];
130
+ const view = viewOfList(meta.views[resource], address.list);
122
131
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
123
132
  const { fields: fieldTypes } = useProjectUi();
124
133
  const columns = useMemo(() => withRelatedDotsColumns(withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), model, view, meta.resources, meta.views), [
@@ -377,7 +386,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
377
386
  onRun: (rows) => remove.startBulk(rows.map(target))
378
387
  }] : []],
379
388
  rowNumbers: false,
380
- pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
389
+ pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, navItemId(resource, address.list)) : void 0,
381
390
  scroll: "parent",
382
391
  className: "flex-1"
383
392
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
@@ -5,6 +5,8 @@ import { userMenuResources } from "./user-menu-from-meta.js";
5
5
  import { LayoutDashboard } from "lucide-react";
6
6
  import { appPath } from "@protobase/schema";
7
7
  //#region src/app/nav-from-meta.ts
8
+ /** The sidebar item of a resource's list, or of one of its named lists. */
9
+ const navItemId = (resource, list) => list === void 0 ? resource : `${resource}/${list}`;
8
10
  const groupLabels = {
9
11
  sales: "Sales",
10
12
  crm: "CRM",
@@ -18,7 +20,8 @@ const groupLabels = {
18
20
  * the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
19
21
  * collapsible. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
20
22
  * read are listed under its label, each with the problem. A visitor also gets the resources whose views ask them to sign
21
- * in, where those views place them, under "Account" without a group.
23
+ * in, where those views place them, under "Account" without a group. Each named list of a view is an entry of its
24
+ * own, placed by its own `nav`.
22
25
  */
23
26
  const navFromMeta = (meta, basePath = "", recent = {}) => {
24
27
  const groups = /* @__PURE__ */ new Map();
@@ -45,15 +48,20 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
45
48
  }
46
49
  for (const model of Object.values(meta.resources)) {
47
50
  const view = meta.views[model.name];
48
- if (!view || view.nav?.hidden || inMenu.has(model.name)) continue;
51
+ if (!view || inMenu.has(model.name)) continue;
49
52
  const schema = model.table.schema ?? "public";
50
- const groupName = view.nav?.group ?? groupLabels[schema] ?? humanize(schema);
51
- const group = groups.get(groupName) ?? {
52
- label: groupName,
53
- items: []
53
+ const defaultGroup = view.nav?.group ?? groupLabels[schema] ?? humanize(schema);
54
+ const add = (groupName, item, order) => {
55
+ const group = groups.get(groupName) ?? {
56
+ label: groupName,
57
+ items: []
58
+ };
59
+ group.items.push(item);
60
+ groups.set(groupName, group);
61
+ orders.set(item.id, order ?? Number.MAX_SAFE_INTEGER);
54
62
  };
55
- group.items.push({
56
- id: model.name,
63
+ if (!view.nav?.hidden) add(defaultGroup, {
64
+ id: navItemId(model.name),
57
65
  label: view.names?.plural ?? humanize(model.name),
58
66
  icon: iconFor(model.name),
59
67
  href: appPath({
@@ -61,9 +69,20 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
61
69
  resource: model.name
62
70
  }, basePath),
63
71
  ...recent[model.name] && { recent: recent[model.name] }
64
- });
65
- groups.set(groupName, group);
66
- orders.set(model.name, view.nav?.order ?? Number.MAX_SAFE_INTEGER);
72
+ }, view.nav?.order);
73
+ for (const named of view.lists ?? []) {
74
+ if (named.nav?.hidden) continue;
75
+ add(named.nav?.group ?? defaultGroup, {
76
+ id: navItemId(model.name, named.name),
77
+ label: named.names.plural,
78
+ icon: iconFor(model.name),
79
+ href: appPath({
80
+ kind: "list",
81
+ resource: model.name,
82
+ list: named.name
83
+ }, basePath)
84
+ }, named.nav?.order);
85
+ }
67
86
  }
68
87
  for (const entry of Object.values(meta.signIn ?? {})) {
69
88
  if (entry.nav?.hidden || inMenu.has(entry.resource)) continue;
@@ -108,4 +127,4 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
108
127
  }));
109
128
  };
110
129
  //#endregion
111
- export { navFromMeta };
130
+ export { navFromMeta, navItemId };
@@ -23,8 +23,8 @@ const useAppAddress = () => {
23
23
  query,
24
24
  pageNames
25
25
  ]);
26
- const defaults = useCallback((resource) => {
27
- const listed = listedResource(resources, views, resource);
26
+ const defaults = useCallback((resource, list) => {
27
+ const listed = listedResource(resources, views, resource, list);
28
28
  return listDefaults(listed.model, listed.view, xs);
29
29
  }, [
30
30
  resources,
@@ -35,7 +35,7 @@ const useAppAddress = () => {
35
35
  const go = useCallback((to, options) => navigate(appPath(to), options), [navigate]);
36
36
  const listTo = useCallback((change) => {
37
37
  if (read?.kind !== "list") throw new Error("listTo and set change the list the app shows; use go to move to another address");
38
- return changedList(read, change, defaults(read.resource));
38
+ return changedList(read, change, defaults(read.resource, read.list));
39
39
  }, [read, defaults]);
40
40
  return {
41
41
  address,
@@ -38,7 +38,7 @@ const FilterMenu = ({ label, activeCount, width = 240, defaultOpen = false, chil
38
38
  }), open && /* @__PURE__ */ jsx("div", {
39
39
  className: cn("absolute top-full z-30 mt-1.5 rounded-lg border bg-background p-3 shadow-pop", alignEnd ? "right-0" : "left-0"),
40
40
  style: { width },
41
- children
41
+ children: typeof children === "function" ? children(() => setOpen(false)) : children
42
42
  })]
43
43
  });
44
44
  };
@@ -5,16 +5,22 @@ import { FacetCombobox } from "./facet-combobox.js";
5
5
  import { FacetGroup } from "./facet-group.js";
6
6
  import { FilterMenu } from "./filter-menu.js";
7
7
  import { FilterSection } from "./filter-section.js";
8
+ import { MoreFiltersMenu } from "./more-filters-menu.js";
8
9
  import { RangeFilter } from "./range-filter.js";
10
+ import { activeFilterKeys, barFilters, mergeKeys } from "./secondary-filters.js";
9
11
  import { ToggleRow } from "./toggle-row.js";
10
12
  import { ToolbarRow, toolbarShows } from "./toolbar-row.js";
11
13
  import { useFilterState } from "./use-filter-state.js";
12
- import { Fragment } from "react";
14
+ import { Fragment, useEffect, useState } from "react";
13
15
  import { jsx, jsxs } from "react/jsx-runtime";
14
16
  //#region src/filter-panel/filter-panel.tsx
15
17
  const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabels, defaultOpenMenu, fullWidth, leading, trailing }) => {
16
18
  const [state, setState] = useFilterState(value, defaultValue, onChange);
17
19
  const chips = activeChips(config, state);
20
+ const activeKeys = activeFilterKeys(chips);
21
+ const [revealed, setRevealed] = useState(() => activeKeys);
22
+ const [justPicked, setJustPicked] = useState();
23
+ useEffect(() => setRevealed((current) => mergeKeys(current, activeKeys)), [activeKeys.join(",")]);
18
24
  const chipsView = /* @__PURE__ */ jsx(ActiveFilterChips, {
19
25
  chips,
20
26
  lockedLabels,
@@ -87,24 +93,29 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
87
93
  })
88
94
  ]
89
95
  });
96
+ const { shown, more } = barFilters(config, revealed);
97
+ const onPick = (key) => {
98
+ setRevealed((current) => mergeKeys(current, [key]));
99
+ setJustPicked(key);
100
+ };
90
101
  const menus = [
91
- ...config.facets.map((group) => /* @__PURE__ */ jsx(FilterMenu, {
102
+ ...config.facets.filter((group) => shown.has(`facet:${group.id}`)).map((group) => /* @__PURE__ */ jsx(FilterMenu, {
92
103
  label: group.label,
93
104
  activeCount: (state.facets[group.id] ?? []).length,
94
105
  width: group.combobox ? 280 : void 0,
95
- defaultOpen: defaultOpenMenu === group.id,
106
+ defaultOpen: defaultOpenMenu === group.id || justPicked === `facet:${group.id}`,
96
107
  children: facetView(group, true)
97
108
  }, group.id)),
98
- ...ranges.map((range) => /* @__PURE__ */ jsx(FilterMenu, {
109
+ ...ranges.filter((range) => shown.has(`range:${range.id}`)).map((range) => /* @__PURE__ */ jsx(FilterMenu, {
99
110
  label: range.label,
100
111
  activeCount: state.ranges?.[range.id] ? 1 : 0,
101
112
  width: 280,
102
- defaultOpen: defaultOpenMenu === range.id,
113
+ defaultOpen: defaultOpenMenu === range.id || justPicked === `range:${range.id}`,
103
114
  children: rangeView(range)
104
115
  }, `range:${range.id}`)),
105
- ...dateView ? [/* @__PURE__ */ jsx(Fragment, { children: dateView }, "date")] : []
116
+ ...dateView && shown.has("date") ? [/* @__PURE__ */ jsx(Fragment, { children: dateView }, "date")] : []
106
117
  ];
107
- const toggles = (config.toggles ?? []).map((toggle) => /* @__PURE__ */ jsx("div", {
118
+ const toggles = (config.toggles ?? []).filter((toggle) => shown.has(`toggle:${toggle.id}`)).map((toggle) => /* @__PURE__ */ jsx("div", {
108
119
  className: "flex items-center gap-2 text-muted-foreground",
109
120
  children: /* @__PURE__ */ jsx(ToggleRow, {
110
121
  label: toggle.label,
@@ -115,7 +126,11 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
115
126
  const groups = [
116
127
  leading ? [/* @__PURE__ */ jsx(Fragment, { children: leading }, "leading")] : [],
117
128
  menus,
118
- toggles
129
+ toggles,
130
+ more.length > 0 ? [/* @__PURE__ */ jsx(MoreFiltersMenu, {
131
+ items: more,
132
+ onPick
133
+ }, "more")] : []
119
134
  ];
120
135
  if (!toolbarShows(groups, trailing) && chips.length === 0 && !lockedLabels?.length) return null;
121
136
  return /* @__PURE__ */ jsxs("div", {
@@ -0,0 +1,54 @@
1
+ import { menuItemClass } from "../primitives/menu-item.js";
2
+ import { FilterMenu } from "./filter-menu.js";
3
+ import { menuKeyTarget } from "../primitives/menu-keys.js";
4
+ import { useEffect, useRef } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/filter-panel/more-filters-menu.tsx
7
+ const Items = ({ items, onPick, close }) => {
8
+ const buttons = useRef([]);
9
+ useEffect(() => buttons.current[0]?.focus(), []);
10
+ const onKeyDown = (event) => {
11
+ if (event.key === "Escape") return close();
12
+ const target = menuKeyTarget(event.key, buttons.current.indexOf(document.activeElement), items.length);
13
+ if (target === void 0) return;
14
+ event.preventDefault();
15
+ buttons.current[target]?.focus();
16
+ };
17
+ return /* @__PURE__ */ jsx("ul", {
18
+ role: "menu",
19
+ "aria-label": "More filters",
20
+ onKeyDown,
21
+ className: "-mx-3 -my-3",
22
+ children: items.map((item, index) => /* @__PURE__ */ jsx("li", {
23
+ role: "none",
24
+ children: /* @__PURE__ */ jsx("button", {
25
+ ref: (button) => {
26
+ buttons.current[index] = button;
27
+ },
28
+ type: "button",
29
+ role: "menuitem",
30
+ className: menuItemClass,
31
+ onClick: () => {
32
+ close();
33
+ onPick(item.key);
34
+ },
35
+ children: item.label
36
+ })
37
+ }, item.key))
38
+ });
39
+ };
40
+ /** The secondary filters the bar does not show yet, in a menu at its end; picking one puts it in the bar. */
41
+ const MoreFiltersMenu = ({ items, onPick }) => {
42
+ if (items.length === 0) return null;
43
+ return /* @__PURE__ */ jsx(FilterMenu, {
44
+ label: "More filters",
45
+ activeCount: 0,
46
+ children: (close) => /* @__PURE__ */ jsx(Items, {
47
+ items,
48
+ onPick,
49
+ close
50
+ })
51
+ });
52
+ };
53
+ //#endregion
54
+ export { MoreFiltersMenu };
@@ -0,0 +1,46 @@
1
+ //#region src/filter-panel/secondary-filters.ts
2
+ /** The filter a chip belongs to: `facet:<id>`, `range:<id>`, `toggle:<id>` or `date`. A filter is active exactly when it has a chip. */
3
+ const activeFilterKeys = (chips) => [...new Set(chips.map((chip) => chip.key.split(":").slice(0, 2).join(":")))];
4
+ /** `revealed` itself when it holds every key already, else a copy with the missing keys appended. */
5
+ const mergeKeys = (revealed, keys) => {
6
+ const missing = keys.filter((key) => !revealed.includes(key));
7
+ return missing.length === 0 ? revealed : [...revealed, ...missing];
8
+ };
9
+ /** The filter keys the bar draws, and the secondary filters left for the "More filters" menu, in the order the bar draws them. */
10
+ const barFilters = (config, revealed) => {
11
+ const all = [
12
+ ...config.facets.map((group) => ({
13
+ key: `facet:${group.id}`,
14
+ label: group.label,
15
+ secondary: group.secondary
16
+ })),
17
+ ...(config.ranges ?? []).map((range) => ({
18
+ key: `range:${range.id}`,
19
+ label: range.label,
20
+ secondary: range.secondary
21
+ })),
22
+ ...config.datePresets ? [{
23
+ key: "date",
24
+ label: "Date",
25
+ secondary: config.dateSecondary
26
+ }] : [],
27
+ ...(config.toggles ?? []).map((toggle) => ({
28
+ key: `toggle:${toggle.id}`,
29
+ label: toggle.label,
30
+ secondary: toggle.secondary
31
+ }))
32
+ ];
33
+ const shown = /* @__PURE__ */ new Set();
34
+ const more = [];
35
+ for (const filter of all) if (!filter.secondary || revealed.includes(filter.key)) shown.add(filter.key);
36
+ else more.push({
37
+ key: filter.key,
38
+ label: filter.label
39
+ });
40
+ return {
41
+ shown,
42
+ more
43
+ };
44
+ };
45
+ //#endregion
46
+ export { activeFilterKeys, barFilters, mergeKeys };
@@ -122,7 +122,8 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
122
122
  ...skeleton.dateField && {
123
123
  datePresets: skeleton.datePresets ?? true,
124
124
  dateField: skeleton.dateField,
125
- dateKind: skeleton.dateKind
125
+ dateKind: skeleton.dateKind,
126
+ ...skeleton.dateSecondary && { dateSecondary: true }
126
127
  },
127
128
  toggles: skeleton.toggles?.map((toggle) => ({
128
129
  ...toggle,
@@ -11,14 +11,16 @@ const isNumber = (model, field) => {
11
11
  };
12
12
  /** A filter on a number field is a range slider, whichever widget the view asked for. */
13
13
  const rangeOf = (widget, model) => widget.kind === "range" || widget.kind === "facets" && isNumber(model, widget.field);
14
- const emptyRange = (field) => ({
15
- id: field,
16
- label: field,
14
+ const secondaryOf = (widget) => widget.secondary ? { secondary: true } : {};
15
+ const emptyRange = (widget) => ({
16
+ id: widget.field,
17
+ label: widget.field,
17
18
  min: 0,
18
19
  max: 0,
19
20
  step: 1,
20
21
  histogram: [],
21
- format: String
22
+ format: String,
23
+ ...secondaryOf(widget)
22
24
  });
23
25
  /**
24
26
  * The widgets of a view in the shape `filter-string` talks to; options and counts are filled in later. Facets on a
@@ -34,7 +36,8 @@ const widgetFields = (view, model) => {
34
36
  label: widget.field,
35
37
  options: [],
36
38
  searchable: widget.search,
37
- ...model?.fields[widget.field]?.type === "relation" && { combobox: true }
39
+ ...model?.fields[widget.field]?.type === "relation" && { combobox: true },
40
+ ...secondaryOf(widget)
38
41
  }];
39
42
  if (widget.kind === "flags") return [{
40
43
  id: widget.fields.join("|"),
@@ -44,19 +47,22 @@ const widgetFields = (view, model) => {
44
47
  label: field,
45
48
  count: 0
46
49
  })),
47
- flags: true
50
+ flags: true,
51
+ ...secondaryOf(widget)
48
52
  }];
49
53
  return [];
50
54
  }),
51
- ranges: widgets.flatMap((widget) => widget.kind !== "flags" && rangeOf(widget, model) ? [emptyRange(widget.field)] : []),
55
+ ranges: widgets.flatMap((widget) => widget.kind !== "flags" && rangeOf(widget, model) ? [emptyRange(widget)] : []),
52
56
  ...date && {
53
57
  dateField: date.field,
58
+ ...date.secondary && { dateSecondary: true },
54
59
  dateKind: model?.fields[date.field]?.type === "date" ? "date" : "timestamp",
55
60
  ...date.presets.length > 0 && { datePresets: date.presets }
56
61
  },
57
62
  toggles: widgets.flatMap((widget) => widget.kind === "toggle" ? [{
58
63
  id: widget.field,
59
- label: widget.field
64
+ label: widget.field,
65
+ ...secondaryOf(widget)
60
66
  }] : [])
61
67
  };
62
68
  };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.22.4";
2
+ var version = "0.22.6";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,4 @@
1
+ //#region src/primitives/menu-item.ts
2
+ const menuItemClass = "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60";
3
+ //#endregion
4
+ export { menuItemClass };
@@ -1,10 +1,10 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import "../primitives/menu-item.js";
2
3
  import { usePopoverMenu } from "../primitives/popover-menu.js";
3
4
  import { MoreVertical } from "lucide-react";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
6
  import { createPortal } from "react-dom";
6
7
  //#region src/record-view/action-menu.tsx
7
- const itemClass = "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60";
8
8
  /**
9
9
  * The actions of an action rail as a menu behind a "..." button, as a mobile screen's top bar holds them: each item the
10
10
  * rail's, a link where the rail draws one, disabled with its reason when it cannot run. `className` styles the button.
@@ -39,7 +39,7 @@ const ActionMenu = ({ actions, label = "Actions", className }) => {
39
39
  role: "none",
40
40
  onClick: () => setOpen(false),
41
41
  children: render ? render({
42
- className: itemClass,
42
+ className: "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60",
43
43
  children,
44
44
  role: "menuitem"
45
45
  }) : /* @__PURE__ */ jsx("button", {
@@ -48,7 +48,7 @@ const ActionMenu = ({ actions, label = "Actions", className }) => {
48
48
  onClick: onRun,
49
49
  disabled: Boolean(blocked) || running,
50
50
  title: blocked,
51
- className: itemClass,
51
+ className: "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60",
52
52
  children
53
53
  })
54
54
  }, name);