@protobase/ui 0.12.12 → 0.12.13

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.
@@ -1,6 +1,8 @@
1
+ import { cn } from "../../lib/cn.js";
1
2
  import { humanize } from "../../live/naming.js";
2
3
  import { displayChoices, sortFields } from "./list-displays.js";
3
4
  import { parseSort } from "../list-order.js";
5
+ import { MenuSelect } from "../../primitives/menu-select.js";
4
6
  import { Segmented } from "../../primitives/segmented.js";
5
7
  import { Calendar, Columns3, LayoutGrid, List, SquareKanban, Table2 } from "lucide-react";
6
8
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -14,25 +16,35 @@ const icons = {
14
16
  list: List,
15
17
  calendar: Calendar
16
18
  };
17
- /** The list page's switch between the table and the view's displays; nothing for a list with only its table. */
18
- const DisplaySwitch = ({ view, value, onChange }) => {
19
+ /**
20
+ * The list page's switch between the table and the view's displays, as buttons or, on a phone, as a menu; nothing for a
21
+ * list with only its table.
22
+ */
23
+ const DisplaySwitch = ({ view, value, onChange, menu = false }) => {
19
24
  const choices = displayChoices(view);
20
25
  if (choices.length < 2) return null;
26
+ const options = choices.map((choice) => ({
27
+ value: choice.name,
28
+ label: choice.label,
29
+ icon: choice.icon === void 0 ? void 0 : icons[choice.icon]
30
+ }));
31
+ if (menu) return /* @__PURE__ */ jsx(MenuSelect, {
32
+ label: "Display",
33
+ value,
34
+ onChange,
35
+ options
36
+ });
21
37
  return /* @__PURE__ */ jsx(Segmented, {
22
38
  label: "Display",
23
39
  size: "md",
24
40
  value,
25
41
  onChange,
26
- options: choices.map((choice) => ({
27
- value: choice.name,
28
- label: choice.label,
29
- icon: choice.icon === void 0 ? void 0 : icons[choice.icon]
30
- }))
42
+ options
31
43
  });
32
44
  };
33
45
  const directions = [["asc", "ascending"], ["desc", "descending"]];
34
46
  /** The order of a display without column headers, over the sortable fields it shows and the order the list has now. */
35
- const SortMenu = ({ model, view, display, orderBy, onChange }) => {
47
+ const SortMenu = ({ model, view, display, orderBy, onChange, className }) => {
36
48
  const current = parseSort(orderBy);
37
49
  const fields = sortFields(model, view, display);
38
50
  const names = fields.includes(current.columnId) ? fields : [current.columnId, ...fields];
@@ -41,7 +53,7 @@ const SortMenu = ({ model, view, display, orderBy, onChange }) => {
41
53
  "aria-label": "Sort",
42
54
  value: `${current.columnId} ${current.direction}`,
43
55
  onChange: (event) => onChange(event.target.value),
44
- className: "h-8 rounded-md border border-border-strong bg-background px-2.5 text-[13px] shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
56
+ className: cn("h-8 rounded-md border border-border-strong bg-background px-2.5 text-[13px] shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20", className),
45
57
  children: names.flatMap((name) => directions.map(([direction, word]) => /* @__PURE__ */ jsxs("option", {
46
58
  value: `${name} ${direction}`,
47
59
  children: [
@@ -15,6 +15,7 @@ import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
15
15
  import { DataGrid } from "../data-grid/data-grid.js";
16
16
  import { ActiveFilterChips } from "../filter-panel/active-filter-chips.js";
17
17
  import { activeChips, emptyFilterState, removeChip } from "../filter-panel/filter-state.js";
18
+ import { FilterSection } from "../filter-panel/filter-section.js";
18
19
  import { ToolbarRow } from "../filter-panel/toolbar-row.js";
19
20
  import { FilterPanel } from "../filter-panel/filter-panel.js";
20
21
  import { columnsFromModel } from "../live/columns-from-model.js";
@@ -135,14 +136,15 @@ const LoadedList = ({ model, address }) => {
135
136
  const searchBox = model.search?.length ? /* @__PURE__ */ jsx(ListSearch, {
136
137
  value: search?.kind === "search" ? search.text : "",
137
138
  onCommit: commitSearch,
138
- placeholder: `Search ${plural.toLowerCase()}`
139
+ placeholder: `Search ${plural.toLowerCase()}`,
140
+ className: desktop ? void 0 : "min-h-11"
139
141
  }) : null;
140
142
  const panel = /* @__PURE__ */ jsx(FilterPanel, {
141
143
  layout,
142
144
  config,
143
145
  value: state,
144
146
  onChange: setFilter,
145
- leading: sortMenu,
147
+ leading: layout === "bar" ? sortMenu : void 0,
146
148
  trailing: searchBox
147
149
  });
148
150
  const desktopToolbar = layout === "bar" ? panel : /* @__PURE__ */ jsx(ToolbarRow, {
@@ -150,40 +152,75 @@ const LoadedList = ({ model, address }) => {
150
152
  trailing: searchBox
151
153
  });
152
154
  const chips = activeChips(config, state);
155
+ const chipsRow = /* @__PURE__ */ jsx(ActiveFilterChips, {
156
+ scroll: true,
157
+ chips,
158
+ onRemove: (key) => setFilter(removeChip(state, key)),
159
+ onClear: () => setFilter(emptyFilterState)
160
+ });
161
+ const phoneSort = display && /* @__PURE__ */ jsx(FilterSection, {
162
+ title: "Sort",
163
+ children: /* @__PURE__ */ jsx(SortMenu, {
164
+ model,
165
+ view,
166
+ display,
167
+ orderBy,
168
+ onChange: (next) => set({ orderBy: next }),
169
+ className: "min-h-11 w-full"
170
+ })
171
+ });
153
172
  const phoneFilters = /* @__PURE__ */ jsxs("div", {
154
173
  className: "flex flex-col gap-2",
155
- children: [/* @__PURE__ */ jsxs("div", {
156
- className: "flex items-center gap-2",
157
- children: [/* @__PURE__ */ jsxs(Button, {
158
- className: "min-h-11",
159
- onClick: () => setSheetOpen(true),
160
- "aria-haspopup": "dialog",
161
- children: [
162
- /* @__PURE__ */ jsx(SlidersHorizontal, { className: "size-3.5" }),
163
- "Filters",
164
- chips.length > 0 && ` (${chips.length})`
165
- ]
166
- }), /* @__PURE__ */ jsx("div", {
167
- className: "min-w-0 flex-1",
168
- children: /* @__PURE__ */ jsx(ActiveFilterChips, {
169
- scroll: true,
170
- chips,
171
- onRemove: (key) => setFilter(removeChip(state, key)),
172
- onClear: () => setFilter(emptyFilterState)
174
+ children: [
175
+ /* @__PURE__ */ jsxs("div", {
176
+ className: "flex items-center gap-2",
177
+ children: [/* @__PURE__ */ jsxs(Button, {
178
+ className: "min-h-11",
179
+ onClick: () => setSheetOpen(true),
180
+ "aria-haspopup": "dialog",
181
+ children: [
182
+ /* @__PURE__ */ jsx(SlidersHorizontal, { className: "size-3.5" }),
183
+ "Filters",
184
+ chips.length > 0 && ` (${chips.length})`
185
+ ]
186
+ }), searchBox ?? /* @__PURE__ */ jsx("div", {
187
+ className: "min-w-0 flex-1",
188
+ children: chipsRow
189
+ })]
190
+ }),
191
+ searchBox && chipsRow,
192
+ /* @__PURE__ */ jsx(Sheet, {
193
+ title: "Filters",
194
+ open: sheetOpen,
195
+ onClose: () => setSheetOpen(false),
196
+ children: /* @__PURE__ */ jsx(FilterPanel, {
197
+ layout: "panel",
198
+ fullWidth: true,
199
+ config,
200
+ value: state,
201
+ onChange: setFilter,
202
+ leading: phoneSort
173
203
  })
174
- })]
175
- }), /* @__PURE__ */ jsx(Sheet, {
176
- title: "Filters",
177
- open: sheetOpen,
178
- onClose: () => setSheetOpen(false),
179
- children: /* @__PURE__ */ jsx(FilterPanel, {
180
- layout: "panel",
181
- fullWidth: true,
182
- config,
183
- value: state,
184
- onChange: setFilter
185
204
  })
186
- })]
205
+ ]
206
+ });
207
+ const createButton = can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
208
+ variant: "primary",
209
+ onClick: () => go({
210
+ kind: "create",
211
+ resource
212
+ }),
213
+ children: [
214
+ /* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
215
+ "New ",
216
+ singular.toLowerCase()
217
+ ]
218
+ });
219
+ const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
220
+ view,
221
+ value: display?.name ?? tableDisplay,
222
+ onChange: (name) => set({ display: name }),
223
+ menu
187
224
  });
188
225
  const openRecord = (row) => go({
189
226
  kind: "record",
@@ -230,27 +267,8 @@ const LoadedList = ({ model, address }) => {
230
267
  /* @__PURE__ */ jsx(PageHeader, {
231
268
  title: plural,
232
269
  subtitle: view?.help,
233
- actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [
234
- /* @__PURE__ */ jsx(DisplaySwitch, {
235
- view,
236
- value: display?.name ?? tableDisplay,
237
- onChange: (name) => set({ display: name })
238
- }),
239
- !desktop && sortMenu,
240
- !desktop && searchBox,
241
- can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
242
- variant: "primary",
243
- onClick: () => go({
244
- kind: "create",
245
- resource
246
- }),
247
- children: [
248
- /* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
249
- "New ",
250
- singular.toLowerCase()
251
- ]
252
- })
253
- ] })
270
+ aside: !desktop && displaySwitch(true),
271
+ actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [displaySwitch(false), createButton] }) : createButton
254
272
  }),
255
273
  !parsed.ok && /* @__PURE__ */ jsx(ErrorBanner, {
256
274
  title: "The filter in the address could not be read",
@@ -1,10 +1,11 @@
1
+ import { cn } from "../lib/cn.js";
1
2
  import { Input } from "../primitives/input.js";
2
3
  import { useEffect, useState } from "react";
3
4
  import { Search } from "lucide-react";
4
5
  import { jsx } from "react/jsx-runtime";
5
6
  //#region src/app/list-search.tsx
6
- /** A search box that commits after a short pause or on Enter. */
7
- const ListSearch = ({ value, onCommit, placeholder }) => {
7
+ /** A search box that commits after a short pause or on Enter; `className` goes on its frame. */
8
+ const ListSearch = ({ value, onCommit, placeholder, className }) => {
8
9
  const [text, setText] = useState(value);
9
10
  useEffect(() => setText(value), [value]);
10
11
  useEffect(() => {
@@ -17,7 +18,7 @@ const ListSearch = ({ value, onCommit, placeholder }) => {
17
18
  onCommit
18
19
  ]);
19
20
  return /* @__PURE__ */ jsx(Input, {
20
- wrapperClassName: "min-w-0 flex-1 sm:w-72 sm:flex-initial",
21
+ wrapperClassName: cn("min-w-0 flex-1 sm:w-72 sm:flex-initial", className),
21
22
  leading: /* @__PURE__ */ jsx(Search, { className: "size-4" }),
22
23
  placeholder,
23
24
  "aria-label": "Search",
@@ -1,17 +1,23 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  //#region src/app-shell/page-header.tsx
3
- const PageHeader = ({ title, subtitle, actions }) => /* @__PURE__ */ jsxs("div", {
4
- className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2",
5
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
3
+ const PageHeader = ({ title, subtitle, aside, actions }) => {
4
+ const heading = /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
6
5
  className: "text-xl font-semibold tracking-tight",
7
6
  children: title
8
7
  }), subtitle && /* @__PURE__ */ jsx("p", {
9
8
  className: "text-[13px] text-muted-foreground",
10
9
  children: subtitle
11
- })] }), /* @__PURE__ */ jsx("div", {
12
- className: "flex w-full flex-wrap items-center gap-2 sm:w-auto",
13
- children: actions
14
- })]
15
- });
10
+ })] });
11
+ return /* @__PURE__ */ jsxs("div", {
12
+ className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2",
13
+ children: [aside ? /* @__PURE__ */ jsxs("div", {
14
+ className: "flex min-w-0 flex-1 items-center justify-between gap-3",
15
+ children: [heading, aside]
16
+ }) : heading, actions && /* @__PURE__ */ jsx("div", {
17
+ className: "flex w-full flex-wrap items-center gap-2 sm:w-auto",
18
+ children: actions
19
+ })]
20
+ });
21
+ };
16
22
  //#endregion
17
23
  export { PageHeader };
@@ -68,6 +68,7 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
68
68
  className: "border-b pb-3",
69
69
  children: chipsView
70
70
  }),
71
+ leading,
71
72
  config.facets.map((group) => /* @__PURE__ */ jsx(FilterSection, {
72
73
  title: group.label,
73
74
  children: facetView(group)
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.12.12";
2
+ var version = "0.12.13";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,14 @@
1
+ //#region src/primitives/menu-keys.ts
2
+ /**
3
+ * The item of an open menu a key moves the focus to, from the item at `index` (-1 for none) of `count`: the arrows step
4
+ * and wrap, Home and End jump to the ends. `undefined` for a key that does not move it.
5
+ */
6
+ const menuKeyTarget = (key, index, count) => {
7
+ if (count === 0) return void 0;
8
+ if (key === "Home") return 0;
9
+ if (key === "End") return count - 1;
10
+ if (key === "ArrowDown") return index < 0 ? 0 : (index + 1) % count;
11
+ if (key === "ArrowUp") return index < 0 ? count - 1 : (index - 1 + count) % count;
12
+ };
13
+ //#endregion
14
+ export { menuKeyTarget };
@@ -0,0 +1,88 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { menuKeyTarget } from "./menu-keys.js";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Check, ChevronDown } from "lucide-react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/primitives/menu-select.tsx
7
+ /**
8
+ * The choices of a Segmented switch in a menu, for a row without room for every option: a button with the chosen
9
+ * option's icon and label, opening the options under its right edge.
10
+ */
11
+ const MenuSelect = ({ value, options, onChange, label }) => {
12
+ const [open, setOpen] = useState(false);
13
+ const root = useRef(null);
14
+ const trigger = useRef(null);
15
+ const items = useRef([]);
16
+ const chosen = options.findIndex((option) => option.value === value);
17
+ const current = options[chosen];
18
+ useEffect(() => {
19
+ if (!open) return;
20
+ items.current[Math.max(chosen, 0)]?.focus();
21
+ const close = (event) => {
22
+ if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false);
23
+ };
24
+ document.addEventListener("pointerdown", close);
25
+ return () => document.removeEventListener("pointerdown", close);
26
+ }, [open]);
27
+ const closeToTrigger = () => {
28
+ setOpen(false);
29
+ trigger.current?.focus();
30
+ };
31
+ const onKeyDown = (event) => {
32
+ if (event.key === "Escape") return closeToTrigger();
33
+ if (event.key === "Tab") return setOpen(false);
34
+ const target = menuKeyTarget(event.key, items.current.indexOf(document.activeElement), options.length);
35
+ if (target === void 0) return;
36
+ event.preventDefault();
37
+ items.current[target]?.focus();
38
+ };
39
+ return /* @__PURE__ */ jsxs("div", {
40
+ ref: root,
41
+ className: "relative shrink-0",
42
+ children: [/* @__PURE__ */ jsxs("button", {
43
+ ref: trigger,
44
+ type: "button",
45
+ "aria-label": current ? `${label}: ${current.label}` : label,
46
+ "aria-haspopup": "menu",
47
+ "aria-expanded": open,
48
+ onClick: () => setOpen(!open),
49
+ className: "inline-flex h-8 items-center gap-1.5 rounded-md border border-border-strong bg-background px-2.5 text-[13px] font-medium shadow-sm transition-colors hover:bg-muted pointer-coarse:h-11",
50
+ children: [
51
+ current?.icon && /* @__PURE__ */ jsx(current.icon, { className: "size-3.5" }),
52
+ current?.label ?? label,
53
+ /* @__PURE__ */ jsx(ChevronDown, { className: "size-3.5 text-muted-foreground" })
54
+ ]
55
+ }), open && /* @__PURE__ */ jsx("ul", {
56
+ role: "menu",
57
+ "aria-label": label,
58
+ onKeyDown,
59
+ className: "absolute right-0 top-full z-30 mt-1 min-w-40 rounded-md border bg-background py-1 text-[13px] shadow-pop",
60
+ children: options.map((option, index) => /* @__PURE__ */ jsx("li", {
61
+ role: "none",
62
+ children: /* @__PURE__ */ jsxs("button", {
63
+ ref: (element) => {
64
+ items.current[index] = element;
65
+ },
66
+ type: "button",
67
+ role: "menuitemradio",
68
+ "aria-checked": option.value === value,
69
+ onClick: () => {
70
+ closeToTrigger();
71
+ if (option.value !== value) onChange(option.value);
72
+ },
73
+ className: cn("flex min-h-9 w-full items-center gap-2 whitespace-nowrap px-3 py-1.5 text-left hover:bg-muted focus:bg-muted focus:outline-none pointer-coarse:min-h-11", option.value === value && "font-medium"),
74
+ children: [
75
+ option.icon ? /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }) : /* @__PURE__ */ jsx("span", { className: "size-3.5" }),
76
+ /* @__PURE__ */ jsx("span", {
77
+ className: "flex-1",
78
+ children: option.label
79
+ }),
80
+ option.value === value && /* @__PURE__ */ jsx(Check, { className: "size-3.5 text-primary-text" })
81
+ ]
82
+ })
83
+ }, option.value))
84
+ })]
85
+ });
86
+ };
87
+ //#endregion
88
+ export { MenuSelect };