@protobase/ui 0.54.6 → 0.55.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 (34) hide show
  1. package/dist/app/app.js +1 -1
  2. package/dist/app/create/create-values.js +5 -5
  3. package/dist/app/field-editor.js +1 -1
  4. package/dist/app/field-types/built-in-fields.js +6 -0
  5. package/dist/app/field-types/members/member-chip.js +20 -0
  6. package/dist/app/field-types/members/member-ids.js +13 -0
  7. package/dist/app/field-types/members/members-display.js +44 -0
  8. package/dist/app/field-types/members/members-editor.js +44 -0
  9. package/dist/app/field-types/members/members-field.js +16 -0
  10. package/dist/app/field-types/members/messages.js +18 -0
  11. package/dist/app/field-types/members/use-members-field.js +18 -0
  12. package/dist/app/field-types/shown-widget.js +11 -6
  13. package/dist/app/picker/picker-combobox.js +140 -0
  14. package/dist/app/relation-picker.js +27 -124
  15. package/dist/filter-panel/facet-combobox.js +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/live/field-values.js +22 -1
  18. package/dist/organizations/organizations-page.js +1 -1
  19. package/dist/package.js +1 -1
  20. package/dist/shell/assets/app-tBBnYXLP.js +9 -0
  21. package/dist/shell/assets/{index-IMD8ceia.js → index-DvCa5s54.js} +2 -2
  22. package/dist/shell/assets/{records-map-TXpdTT7s.js → records-map-BWSeQRmm.js} +2 -2
  23. package/dist/shell/assets/shared-protobase-ui-Br4XDldp.js +2 -0
  24. package/dist/shell/index.html +3 -3
  25. package/dist/shell/protobase.shell.json +1 -1
  26. package/dist/shell/protobase.sw.js +2 -2
  27. package/dist/shell-source-maps/assets/app-tBBnYXLP.js.map +1 -0
  28. package/dist/shell-source-maps/assets/{index-IMD8ceia.js.map → index-DvCa5s54.js.map} +1 -1
  29. package/dist/shell-source-maps/assets/{records-map-TXpdTT7s.js.map → records-map-BWSeQRmm.js.map} +1 -1
  30. package/dist/shell-source-maps/assets/{shared-protobase-ui-BHIhZYVr.js.map → shared-protobase-ui-Br4XDldp.js.map} +1 -1
  31. package/package.json +4 -4
  32. package/dist/shell/assets/app-Y_3vlU3f.js +0 -9
  33. package/dist/shell/assets/shared-protobase-ui-BHIhZYVr.js +0 -2
  34. package/dist/shell-source-maps/assets/app-Y_3vlU3f.js.map +0 -1
package/dist/app/app.js CHANGED
@@ -11,6 +11,7 @@ import { text } from "./messages.js";
11
11
  import { Router, useRouter } from "./router.js";
12
12
  import { useAppAddress } from "./use-app-address.js";
13
13
  import { ProjectUiContext, useProjectUi } from "./pages/project-ui.js";
14
+ import { isAdmin, userToShell } from "../auth/user-to-shell.js";
14
15
  import { startErrorReporting } from "./error-reporting.js";
15
16
  import { slotted } from "../locale/slotted.js";
16
17
  import { CustomBoundary } from "./pages/custom-boundary.js";
@@ -25,7 +26,6 @@ import { ConnectRoute } from "../connect/connect-route.js";
25
26
  import { SignInPolicyPage } from "../account/sign-in-policy-page.js";
26
27
  import { PluginSourceProvider } from "../plugins/plugin-source.js";
27
28
  import { AuthGate } from "../auth/auth-gate.js";
28
- import { isAdmin, userToShell } from "../auth/user-to-shell.js";
29
29
  import { AuthLocale } from "../auth/auth-locale.js";
30
30
  import { useLiveUpdates } from "../data/use-live-updates.js";
31
31
  import { useOpenedRecord } from "../data/use-record.js";
@@ -1,19 +1,19 @@
1
- import { isEditable, toPatchValue } from "../../live/field-values.js";
1
+ import { isEditable, toDraft, toPatchValue } from "../../live/field-values.js";
2
2
  import { whenShown } from "../record-mode/record-mode.js";
3
3
  import { text } from "./messages.js";
4
4
  import { layoutItems } from "@protobase/schema/internal";
5
5
  import { isRecordCondition } from "@protobase/schema";
6
6
  //#region src/app/create/create-values.ts
7
7
  const isChosen = (value) => typeof value === "object" && value !== null && "key" in value;
8
- /** Fields the user can set on a new record: not read-only, not the tenant, and not structured data. */
9
- const isCreatable = (model, field) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field));
8
+ /** Fields the user can set on a new record: not read-only, not the tenant, and no JSON but through a field type (`isEditable`). */
9
+ const isCreatable = (model, field, hints) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field, hints));
10
10
  /**
11
11
  * The view's sections limited to creatable fields, plus a section for any creatable field the layout leaves out. The
12
12
  * create form is a form, so sections and fields meant only for reading the record stay out of their sections, and a
13
13
  * creatable field among them lands in that last section. A write-only field is on the form only where a section names it.
14
14
  */
15
15
  const createSections = (model, view) => {
16
- const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field)).map((field) => field.name);
16
+ const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field, view?.fields[field.name])).map((field) => field.name);
17
17
  const placed = /* @__PURE__ */ new Set();
18
18
  const sections = layoutItems(view?.layout ?? []).flatMap((item) => {
19
19
  if (item.kind !== "section" || isRecordCondition(item.when) || !whenShown(item.when, true)) return [];
@@ -83,7 +83,7 @@ const initialDraft = (model, sections, given = {}) => Object.fromEntries(section
83
83
  const fallback = field.default;
84
84
  if (numberDefault(field) !== void 0) return [];
85
85
  if (!fallback || !("value" in fallback) || fallback.value === null || fallback.value === void 0) return [];
86
- return [[name, typeof fallback.value === "boolean" ? fallback.value : String(fallback.value)]];
86
+ return [[name, typeof fallback.value === "boolean" ? fallback.value : toDraft(fallback.value)]];
87
87
  })));
88
88
  const hasDatabaseDefault = (field) => Boolean(field.default && "db" in field.default);
89
89
  //#endregion
@@ -156,7 +156,7 @@ const ChosenEditor = (props) => {
156
156
  const shown = useShownWidget(field, hints);
157
157
  const builtIn = /* @__PURE__ */ jsx(BuiltInEditor, { ...props });
158
158
  if (!shown) return builtIn;
159
- if (locked || !isEditable(field)) return /* @__PURE__ */ jsx(WidgetPart, {
159
+ if (locked || !isEditable(field, hints)) return /* @__PURE__ */ jsx(WidgetPart, {
160
160
  shown,
161
161
  part: "display",
162
162
  field,
@@ -0,0 +1,6 @@
1
+ import { membersField } from "./members/members-field.js";
2
+ //#region src/app/field-types/built-in-fields.ts
3
+ /** The field types Protobase brings, which a view chooses by name like a plugin's; a plugin's of the same name wins. */
4
+ const builtInFields = { members: membersField };
5
+ //#endregion
6
+ export { builtInFields };
@@ -0,0 +1,20 @@
1
+ import { Avatar } from "../../../primitives/avatar.js";
2
+ import { hueOf, initialsOf } from "../../../auth/user-to-shell.js";
3
+ import { text } from "./messages.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/app/field-types/members/member-chip.tsx
6
+ /** A member by avatar and name, as chat messages and comments draw their authors; a name of `undefined` is someone unknown. */
7
+ const MemberChip = ({ id, name }) => /* @__PURE__ */ jsxs("span", {
8
+ className: "inline-flex min-w-0 items-center gap-1.5",
9
+ children: [/* @__PURE__ */ jsx(Avatar, {
10
+ initials: name && initialsOf(name) || "?",
11
+ hue: hueOf(id),
12
+ size: "xs",
13
+ className: "ring-0"
14
+ }), /* @__PURE__ */ jsx("span", {
15
+ className: name === void 0 ? "truncate text-muted-foreground" : "truncate",
16
+ children: name ?? text("unknownMember")
17
+ })]
18
+ });
19
+ //#endregion
20
+ export { MemberChip };
@@ -0,0 +1,13 @@
1
+ import { titleMatches } from "../../relation-options.js";
2
+ //#region src/app/field-types/members/member-ids.ts
3
+ /** The user ids a `members` field holds: the strings of its array, nothing for a value that is none. */
4
+ const memberIdsOf = (value) => Array.isArray(value) ? value.filter((id) => typeof id === "string") : [];
5
+ /** The ids with `id` added, or taken off when they hold it already. */
6
+ const toggledId = (ids, id) => ids.includes(id) ? ids.filter((held) => held !== id) : [...ids, id];
7
+ /** The members whose name holds every word of `search`, as the picker's options keyed by user id. */
8
+ const memberOptions = (people, search) => people.filter((person) => titleMatches(person.name, search)).map((person) => ({
9
+ key: person.id,
10
+ label: person.name
11
+ }));
12
+ //#endregion
13
+ export { memberIdsOf, memberOptions, toggledId };
@@ -0,0 +1,44 @@
1
+ import { cn } from "../../../lib/cn.js";
2
+ import { Skeleton } from "../../../primitives/skeleton.js";
3
+ import { text } from "./messages.js";
4
+ import { MemberChip } from "./member-chip.js";
5
+ import { memberIdsOf } from "./member-ids.js";
6
+ import { useMembersField } from "./use-members-field.js";
7
+ import { jsx } from "react/jsx-runtime";
8
+ //#region src/app/field-types/members/members-display.tsx
9
+ const Members = ({ value, line }) => {
10
+ const members = useMembersField();
11
+ const ids = memberIdsOf(value);
12
+ if (ids.length === 0) return /* @__PURE__ */ jsx("span", {
13
+ className: "text-muted-foreground",
14
+ children: "—"
15
+ });
16
+ if (members.needsOrganizations) return /* @__PURE__ */ jsx("span", {
17
+ className: "text-muted-foreground",
18
+ children: text("needsOrganizations")
19
+ });
20
+ if (members.failed) return /* @__PURE__ */ jsx("span", {
21
+ className: "text-muted-foreground",
22
+ children: text("couldNotLoadMembers")
23
+ });
24
+ if (members.isLoading) return /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" });
25
+ return /* @__PURE__ */ jsx("span", {
26
+ className: cn("flex min-w-0 items-center gap-x-3 gap-y-1", line ? "overflow-hidden" : "flex-wrap"),
27
+ children: ids.map((id) => /* @__PURE__ */ jsx(MemberChip, {
28
+ id,
29
+ name: members.member(id)?.name
30
+ }, id))
31
+ });
32
+ };
33
+ /** The members a field holds by avatar and name, on record pages, cards and page blocks; one no longer a member as unknown. */
34
+ const MembersDisplay = (props) => /* @__PURE__ */ jsx(Members, {
35
+ ...props,
36
+ line: false
37
+ });
38
+ /** The members a field holds on one line, in a list's table. */
39
+ const MembersCell = (props) => /* @__PURE__ */ jsx(Members, {
40
+ ...props,
41
+ line: true
42
+ });
43
+ //#endregion
44
+ export { MembersCell, MembersDisplay };
@@ -0,0 +1,44 @@
1
+ import { PickerCombobox, usePickerSearch } from "../../picker/picker-combobox.js";
2
+ import { text } from "./messages.js";
3
+ import { memberIdsOf, memberOptions, toggledId } from "./member-ids.js";
4
+ import { useMembersField } from "./use-members-field.js";
5
+ import { Token } from "../../../primitives/token.js";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/field-types/members/members-editor.tsx
8
+ /**
9
+ * Picks the organization's members into the field's array of user ids, as `RecordPicker multiple` picks records: type
10
+ * to narrow them by name, each choice a token that takes it off again, and choosing a chosen member takes them off.
11
+ */
12
+ const MembersEditor = ({ value, invalid, onChange }) => {
13
+ const search = usePickerSearch();
14
+ const members = useMembersField();
15
+ const ids = memberIdsOf(value);
16
+ if (members.needsOrganizations) return /* @__PURE__ */ jsx("p", {
17
+ className: "py-1.5 text-muted-foreground",
18
+ children: text("needsOrganizations")
19
+ });
20
+ const labelOf = (id) => members.member(id)?.name ?? text("unknownMember");
21
+ return /* @__PURE__ */ jsxs("div", {
22
+ className: "flex flex-col gap-2",
23
+ children: [/* @__PURE__ */ jsx(PickerCombobox, {
24
+ search,
25
+ options: memberOptions(members.data ?? [], search.text),
26
+ chosen: ids,
27
+ several: true,
28
+ closedText: "",
29
+ placeholder: text("searchMembers"),
30
+ invalid,
31
+ loading: members.isLoading,
32
+ failed: members.failed,
33
+ onChoose: (option) => onChange(toggledId(ids, option.key))
34
+ }), ids.length > 0 && /* @__PURE__ */ jsx("div", {
35
+ className: "flex flex-wrap gap-1",
36
+ children: ids.map((id) => /* @__PURE__ */ jsx(Token, {
37
+ label: labelOf(id),
38
+ onRemove: () => onChange(ids.filter((held) => held !== id))
39
+ }, id))
40
+ })]
41
+ });
42
+ };
43
+ //#endregion
44
+ export { MembersEditor };
@@ -0,0 +1,16 @@
1
+ import { MembersCell, MembersDisplay } from "./members-display.js";
2
+ import { MembersEditor } from "./members-editor.js";
3
+ import { builtInFieldTypes } from "@protobase/schema/internal";
4
+ //#region src/app/field-types/members/members-field.ts
5
+ /**
6
+ * The built-in `members` field type, for a JSON array of the user ids of members of the organization the session works
7
+ * in: `r.member_ids.widget('members')`. See the field types reference.
8
+ */
9
+ const membersField = {
10
+ of: [...builtInFieldTypes.members],
11
+ display: MembersDisplay,
12
+ editor: MembersEditor,
13
+ cell: MembersCell
14
+ };
15
+ //#endregion
16
+ export { membersField };
@@ -0,0 +1,18 @@
1
+ import { defineMessages } from "../../../locale/messages.js";
2
+ //#region src/app/field-types/members/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ needsOrganizations: "Choosing members needs organizations: turn them on in the auth config.",
6
+ couldNotLoadMembers: "Could not load the members.",
7
+ unknownMember: "Unknown member",
8
+ searchMembers: "Search members"
9
+ },
10
+ nl: {
11
+ needsOrganizations: "Leden kiezen vraagt om organisaties: zet ze aan in de auth-configuratie.",
12
+ couldNotLoadMembers: "De leden konden niet worden geladen.",
13
+ unknownMember: "Onbekend lid",
14
+ searchMembers: "Leden zoeken"
15
+ }
16
+ });
17
+ //#endregion
18
+ export { text };
@@ -0,0 +1,18 @@
1
+ import { useOptionalAuth } from "../../../auth/auth-provider.js";
2
+ import { useMembers } from "../../../people/use-members.js";
3
+ //#region src/app/field-types/members/use-members-field.ts
4
+ /**
5
+ * The organization's members for a `members` field, and what keeps it from showing them: `needsOrganizations` in an
6
+ * app without them, where `useMembers` would fail, and `failed` when they could not be read.
7
+ */
8
+ const useMembersField = () => {
9
+ const auth = useOptionalAuth();
10
+ const members = useMembers();
11
+ return {
12
+ ...members,
13
+ needsOrganizations: !auth?.organizations,
14
+ failed: members.isError
15
+ };
16
+ };
17
+ //#endregion
18
+ export { useMembersField };
@@ -1,15 +1,20 @@
1
1
  import { useProjectUi } from "../pages/project-ui.js";
2
+ import { builtInFields } from "./built-in-fields.js";
2
3
  //#region src/app/field-types/shown-widget.ts
4
+ const named = (fields, name) => {
5
+ if (fields && Object.hasOwn(fields, name)) return fields[name];
6
+ if (Object.hasOwn(builtInFields, name)) return builtInFields[name];
7
+ };
3
8
  /**
4
- * The field type the view's `hints` choose for `field`, when one of `fields` has that name and shows the field's
5
- * storage type; otherwise undefined, and the field shows as it would without one. A sensitive field never comes with
6
- * its value, so it keeps its own.
9
+ * The field type the view's `hints` choose for `field`, when one of `fields` or a built-in one (`builtInFields`) has
10
+ * that name and shows the field's storage type; otherwise undefined, and the field shows as it would without one. A
11
+ * sensitive field never comes with its value, so it keeps its own.
7
12
  */
8
13
  const shownWidget = (fields, field, hints) => {
9
14
  const chosen = hints?.widget;
10
- if (!chosen || field.sensitive || !fields || !Object.hasOwn(fields, chosen.name)) return void 0;
11
- const widget = fields[chosen.name];
12
- return widget.of.includes(field.type) ? {
15
+ if (!chosen || field.sensitive) return void 0;
16
+ const widget = named(fields, chosen.name);
17
+ return widget?.of.includes(field.type) ? {
13
18
  name: chosen.name,
14
19
  widget,
15
20
  props: chosen.props ?? {}
@@ -0,0 +1,140 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Input } from "../../primitives/input.js";
3
+ import { text } from "../messages.js";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/app/picker/picker-combobox.tsx
7
+ const usePickerSearch = () => {
8
+ const [text, setText] = useState("");
9
+ const [open, setOpen] = useState(false);
10
+ return {
11
+ text,
12
+ setText,
13
+ open,
14
+ setOpen
15
+ };
16
+ };
17
+ /** Loads the next page while `sentinel` is in view of the list; it re-arms after each page, in case it still is. */
18
+ const useLoadMoreInView = (list, sentinel, loaded, loadMore) => {
19
+ useEffect(() => {
20
+ if (!list || !sentinel || !loadMore) return;
21
+ const observer = new IntersectionObserver((entries) => entries.some((entry) => entry.isIntersecting) && loadMore(), { root: list });
22
+ observer.observe(sentinel);
23
+ return () => observer.disconnect();
24
+ }, [
25
+ list,
26
+ sentinel,
27
+ loaded,
28
+ loadMore
29
+ ]);
30
+ };
31
+ /**
32
+ * A box to type in that lists the options its owner reads for the text: choose with the mouse or with the arrow keys
33
+ * and Enter; Escape closes the list. The pickers of records and of members draw their lists with it.
34
+ */
35
+ const PickerCombobox = ({ search, options, chosen, several, closedText, placeholder, invalid, paging, loading, failed, onChoose }) => {
36
+ const { text: text$1, setText, open, setOpen } = search;
37
+ const [active, setActive] = useState(-1);
38
+ const [list, setList] = useState(null);
39
+ const [sentinel, setSentinel] = useState(null);
40
+ const listId = useId();
41
+ const more = paging?.more ?? false;
42
+ useLoadMoreInView(list, sentinel, paging?.loaded ?? 0, paging?.loadMore);
43
+ const optionId = (index) => `${listId}-${index}`;
44
+ const input = useRef(null);
45
+ useEffect(() => {
46
+ if (!open && document.activeElement === input.current) input.current?.select();
47
+ }, [open]);
48
+ useEffect(() => {
49
+ if (open && active >= 0) document.getElementById(optionId(active))?.scrollIntoView({ block: "nearest" });
50
+ });
51
+ const close = () => {
52
+ setText("");
53
+ setActive(-1);
54
+ setOpen(false);
55
+ };
56
+ const choose = (option) => {
57
+ onChoose(option);
58
+ if (!several) return close();
59
+ setText("");
60
+ setActive(-1);
61
+ };
62
+ const onKeyDown = (event) => {
63
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
64
+ event.preventDefault();
65
+ if (!open) return setOpen(true);
66
+ const step = event.key === "ArrowDown" ? 1 : -1;
67
+ setActive((current) => Math.min(Math.max(current + step, 0), options.length - 1));
68
+ return;
69
+ }
70
+ if (event.key === "Enter" && open && options[active]) {
71
+ event.preventDefault();
72
+ choose(options[active]);
73
+ return;
74
+ }
75
+ if (event.key === "Escape" && open) {
76
+ event.preventDefault();
77
+ event.stopPropagation();
78
+ close();
79
+ }
80
+ };
81
+ return /* @__PURE__ */ jsxs("div", {
82
+ className: "relative",
83
+ children: [/* @__PURE__ */ jsx(Input, {
84
+ ref: input,
85
+ role: "combobox",
86
+ "aria-autocomplete": "list",
87
+ "aria-expanded": open,
88
+ "aria-controls": listId,
89
+ "aria-activedescendant": open && options[active] ? optionId(active) : void 0,
90
+ invalid,
91
+ placeholder,
92
+ value: open ? text$1 : closedText,
93
+ onFocus: () => setOpen(true),
94
+ onBlur: close,
95
+ onKeyDown,
96
+ onChange: (event) => {
97
+ setText(event.target.value);
98
+ setActive(event.target.value.trim() ? 0 : -1);
99
+ setOpen(true);
100
+ }
101
+ }), open && /* @__PURE__ */ jsxs("ul", {
102
+ ref: setList,
103
+ id: listId,
104
+ role: "listbox",
105
+ "aria-multiselectable": several || void 0,
106
+ className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 shadow-pop",
107
+ onMouseDown: (event) => event.preventDefault(),
108
+ children: [
109
+ options.map((option, index) => /* @__PURE__ */ jsx("li", {
110
+ id: optionId(index),
111
+ role: "option",
112
+ "aria-selected": index === active,
113
+ className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", chosen.includes(option.key) && "font-medium"),
114
+ onMouseMove: () => index !== active && setActive(index),
115
+ onClick: () => choose(option),
116
+ children: option.label
117
+ }, option.key)),
118
+ more && /* @__PURE__ */ jsx("li", {
119
+ ref: setSentinel,
120
+ "aria-hidden": true,
121
+ className: "h-px"
122
+ }),
123
+ failed && /* @__PURE__ */ jsx("li", {
124
+ className: "px-3 py-1.5 text-muted-foreground",
125
+ children: text("couldNotLoadOptions")
126
+ }),
127
+ !more && !loading && !failed && options.length === 0 && /* @__PURE__ */ jsx("li", {
128
+ className: "px-3 py-1.5 text-muted-foreground",
129
+ children: text("noMatches")
130
+ }),
131
+ loading && /* @__PURE__ */ jsx("li", {
132
+ className: "px-3 py-1.5 text-muted-foreground",
133
+ children: text("loading")
134
+ })
135
+ ]
136
+ })]
137
+ });
138
+ };
139
+ //#endregion
140
+ export { PickerCombobox, usePickerSearch };
@@ -1,151 +1,54 @@
1
- import { cn } from "../lib/cn.js";
2
- import { Input } from "../primitives/input.js";
3
1
  import { humanize } from "../live/naming.js";
4
2
  import { labelFieldOf } from "../live/model-helpers.js";
5
3
  import { text } from "./messages.js";
6
4
  import { useList } from "../data/use-list.js";
5
+ import { PickerCombobox, usePickerSearch } from "./picker/picker-combobox.js";
7
6
  import { relationOptionsQuery, titleMatches } from "./relation-options.js";
8
- import { useCallback, useEffect, useId, useRef, useState } from "react";
7
+ import { useCallback, useEffect, useState } from "react";
9
8
  import { ApiError } from "@protobase/client";
10
- import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { jsx } from "react/jsx-runtime";
11
10
  //#region src/app/relation-picker.tsx
12
- /** Loads the next page while `sentinel` is in view of the list; it re-arms after each page, in case it still is. */
13
- const useLoadMoreInView = (list, sentinel, loaded, loadMore) => {
14
- useEffect(() => {
15
- if (!list || !sentinel) return;
16
- const observer = new IntersectionObserver((entries) => entries.some((entry) => entry.isIntersecting) && loadMore(), { root: list });
17
- observer.observe(sentinel);
18
- return () => observer.disconnect();
19
- }, [
20
- list,
21
- sentinel,
22
- loaded,
23
- loadMore
24
- ]);
25
- };
26
11
  /**
27
12
  * Picks a record of another resource: type to narrow the records by their search fields or title, scroll for more, and
28
13
  * choose with the mouse or with the arrow keys and Enter; Escape closes the list.
29
14
  */
30
15
  const RelationPicker = ({ target, view, value, label, chosen = [value], several = false, invalid, onChange }) => {
31
- const [text$1, setText] = useState("");
32
- const [open, setOpen] = useState(false);
33
- const [active, setActive] = useState(-1);
34
- const [list, setList] = useState(null);
35
- const [sentinel, setSentinel] = useState(null);
36
- const listId = useId();
16
+ const search = usePickerSearch();
37
17
  const keyField = target.primaryKey[0];
38
18
  const labelField = labelFieldOf(target, view);
39
19
  const [refused, setRefused] = useState(false);
40
- const query = relationOptionsQuery(target, labelField, text$1, refused);
20
+ const query = relationOptionsQuery(target, labelField, search.text, refused);
41
21
  const rows = useList(target.name, {
42
22
  filter: query.filter,
43
23
  fields: [keyField, labelField],
44
- enabled: open
24
+ enabled: search.open
45
25
  });
46
26
  const expensive = query.filter !== void 0 && rows.error instanceof ApiError && rows.error.slug === "expensive-query";
47
27
  useEffect(() => {
48
28
  if (expensive) setRefused(true);
49
29
  }, [expensive]);
50
- const items = query.local ? rows.items.filter((item) => titleMatches(item[labelField], text$1)) : rows.items;
51
- const more = Boolean(rows.hasNextPage);
52
- const loading = rows.isPending || rows.isFetchingNextPage;
30
+ const items = query.local ? rows.items.filter((item) => titleMatches(item[labelField], search.text)) : rows.items;
53
31
  const { fetchNextPage } = rows;
54
- useLoadMoreInView(list, sentinel, rows.items.length, useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage]));
55
- const optionId = (index) => `${listId}-${index}`;
56
- const input = useRef(null);
57
- useEffect(() => {
58
- if (!open && document.activeElement === input.current) input.current?.select();
59
- }, [open]);
60
- useEffect(() => {
61
- if (open && active >= 0) document.getElementById(optionId(active))?.scrollIntoView({ block: "nearest" });
62
- });
63
- const close = () => {
64
- setText("");
65
- setActive(-1);
66
- setOpen(false);
67
- };
68
- const choose = (item) => {
69
- onChange(String(item[keyField]), String(item[labelField]));
70
- if (!several) return close();
71
- setText("");
72
- setActive(-1);
73
- };
74
- const onKeyDown = (event) => {
75
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
76
- event.preventDefault();
77
- if (!open) return setOpen(true);
78
- const step = event.key === "ArrowDown" ? 1 : -1;
79
- setActive((current) => Math.min(Math.max(current + step, 0), items.length - 1));
80
- return;
81
- }
82
- if (event.key === "Enter" && open && items[active]) {
83
- event.preventDefault();
84
- choose(items[active]);
85
- return;
86
- }
87
- if (event.key === "Escape" && open) {
88
- event.preventDefault();
89
- event.stopPropagation();
90
- close();
91
- }
92
- };
93
- return /* @__PURE__ */ jsxs("div", {
94
- className: "relative",
95
- children: [/* @__PURE__ */ jsx(Input, {
96
- ref: input,
97
- role: "combobox",
98
- "aria-autocomplete": "list",
99
- "aria-expanded": open,
100
- "aria-controls": listId,
101
- "aria-activedescendant": open && items[active] ? optionId(active) : void 0,
102
- invalid,
103
- placeholder: text("searchPlural", (view?.names?.plural ?? humanize(target.name)).toLowerCase()),
104
- value: open ? text$1 : label ?? value,
105
- onFocus: () => setOpen(true),
106
- onBlur: close,
107
- onKeyDown,
108
- onChange: (event) => {
109
- setText(event.target.value);
110
- setActive(event.target.value.trim() ? 0 : -1);
111
- setOpen(true);
112
- }
113
- }), open && /* @__PURE__ */ jsxs("ul", {
114
- ref: setList,
115
- id: listId,
116
- role: "listbox",
117
- "aria-multiselectable": several || void 0,
118
- className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 shadow-pop",
119
- onMouseDown: (event) => event.preventDefault(),
120
- children: [
121
- items.map((item, index) => /* @__PURE__ */ jsx("li", {
122
- id: optionId(index),
123
- role: "option",
124
- "aria-selected": index === active,
125
- className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", chosen.includes(String(item[keyField])) && "font-medium"),
126
- onMouseMove: () => index !== active && setActive(index),
127
- onClick: () => choose(item),
128
- children: String(item[labelField])
129
- }, String(item[keyField]))),
130
- more && /* @__PURE__ */ jsx("li", {
131
- ref: setSentinel,
132
- "aria-hidden": true,
133
- className: "h-px"
134
- }),
135
- rows.error && !expensive && /* @__PURE__ */ jsx("li", {
136
- className: "px-3 py-1.5 text-muted-foreground",
137
- children: text("couldNotLoadOptions")
138
- }),
139
- !more && !loading && !rows.error && items.length === 0 && /* @__PURE__ */ jsx("li", {
140
- className: "px-3 py-1.5 text-muted-foreground",
141
- children: text("noMatches")
142
- }),
143
- loading && /* @__PURE__ */ jsx("li", {
144
- className: "px-3 py-1.5 text-muted-foreground",
145
- children: text("loading")
146
- })
147
- ]
148
- })]
32
+ const loadMore = useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage]);
33
+ return /* @__PURE__ */ jsx(PickerCombobox, {
34
+ search,
35
+ options: items.map((item) => ({
36
+ key: String(item[keyField]),
37
+ label: String(item[labelField])
38
+ })),
39
+ chosen,
40
+ several,
41
+ closedText: label ?? value,
42
+ placeholder: text("searchPlural", (view?.names?.plural ?? humanize(target.name)).toLowerCase()),
43
+ invalid,
44
+ paging: {
45
+ more: Boolean(rows.hasNextPage),
46
+ loaded: rows.items.length,
47
+ loadMore
48
+ },
49
+ loading: rows.isPending || rows.isFetchingNextPage,
50
+ failed: Boolean(rows.error) && !expensive,
51
+ onChoose: (option) => onChange(option.key, option.label)
149
52
  });
150
53
  };
151
54
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { formatInt } from "../format/number.js";
3
- import { text } from "./messages.js";
4
3
  import { Token } from "../primitives/token.js";
4
+ import { text } from "./messages.js";
5
5
  import { facetLabel } from "./filter-state.js";
6
6
  import { comboboxOptions, moveActive } from "./combobox-options.js";
7
7
  import { useEffect, useId, useRef, useState } from "react";
package/dist/index.js CHANGED
@@ -14,11 +14,11 @@ import { Textarea } from "./primitives/textarea.js";
14
14
  import { useList } from "./data/use-list.js";
15
15
  import { OtpInput } from "./primitives/otp-input.js";
16
16
  import { definePlugin, defineUi } from "./app/pages/project-ui.js";
17
+ import { useMembers } from "./people/use-members.js";
17
18
  import { Markdown } from "./markdown/markdown.js";
18
19
  import { ActionInputError } from "./app/pages/action-inputs/input-errors.js";
19
20
  import { useLayoutRecord } from "./app/pages/record-scope.js";
20
21
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
21
- import { useMembers } from "./people/use-members.js";
22
22
  import { LogoMark } from "./app-shell/logo.js";
23
23
  import { SignInPrompt } from "./auth/sign-in-prompt.js";
24
24
  import { useListDisplay } from "./app/displays/display-context.js";