@protobase/ui 0.23.1 → 0.23.3

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.
@@ -13,6 +13,7 @@ import { Field } from "../record-view/field.js";
13
13
  import { Section } from "../record-view/section.js";
14
14
  import { MetadataCard } from "../record-view/record-sidebar.js";
15
15
  import { RecordHeader } from "../record-view/record-header.js";
16
+ import { markdownExcerpt } from "../markdown/markdown-excerpt.js";
16
17
  import { isMarkdown } from "../markdown/markdown-hint.js";
17
18
  import { recordTitle } from "../live/model-helpers.js";
18
19
  import { contactFormat } from "../live/contact-links.js";
@@ -32,7 +33,6 @@ import { ErrorBanner } from "./error-banner.js";
32
33
  import { useProjectUi } from "./pages/project-ui.js";
33
34
  import { shownWidget } from "./field-types/shown-widget.js";
34
35
  import { WidgetPart } from "./field-types/widget-part.js";
35
- import { Markdown } from "../markdown/markdown.js";
36
36
  import { ContactLink } from "../live/contact-link.js";
37
37
  import { FieldEditor } from "./field-editor.js";
38
38
  import { fieldMessage } from "./field-errors.js";
@@ -303,25 +303,24 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
303
303
  label: hint(name)?.label ?? humanize(name),
304
304
  value: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
305
305
  }];
306
- const text = displayValue(field, stored.record[name], hint(name));
307
306
  const value = stored.record[name];
308
- const markdown = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "";
307
+ const text = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "" ? markdownExcerpt(value) : displayValue(field, value, hint(name));
309
308
  const contact = field.type === "text" && typeof value === "string" && value !== "" ? contactFormat(hint(name)) : void 0;
310
309
  const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
311
310
  value,
312
311
  hints: hint(name),
313
312
  children: text
314
- }) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : contact ? /* @__PURE__ */ jsx(ContactLink, {
313
+ }) : contact ? /* @__PURE__ */ jsx(ContactLink, {
315
314
  format: contact,
316
315
  value: text
317
316
  }) : /* @__PURE__ */ jsx("strong", {
318
317
  title: valueTitle(field, value, void 0, hint(name)),
319
- className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
320
318
  children: text
321
319
  });
322
320
  const shown = shownWidget(fieldTypes, field, hint(name));
323
321
  return [{
324
322
  label: hint(name)?.label ?? humanize(name),
323
+ title: text,
325
324
  value: shown ? /* @__PURE__ */ jsx(WidgetPart, {
326
325
  shown,
327
326
  part: "display",
@@ -0,0 +1,25 @@
1
+ //#region src/app/record-picker.d.ts
2
+ type One = {
3
+ multiple?: false;
4
+ /** The chosen record's key, as text; `undefined` for none. */
5
+ value: string | undefined;
6
+ onChange: (key: string) => void;
7
+ };
8
+ type Several = {
9
+ multiple: true;
10
+ /** The chosen records' keys, as text. */
11
+ value: string[];
12
+ onChange: (keys: string[]) => void;
13
+ };
14
+ export type RecordPickerProps = (One | Several) & {
15
+ /** The name of the resource whose records it picks. */
16
+ resource: string;
17
+ invalid?: boolean;
18
+ };
19
+ /**
20
+ * Picks records of a resource of the app with the create form's relation picker: type to narrow them by their search
21
+ * fields or title, scroll for more, choose with the mouse or the keyboard. With `multiple`, each choice is added as a
22
+ * token that can be taken off again, and choosing a chosen record takes it off.
23
+ */
24
+ export declare const RecordPicker: (props: RecordPickerProps) => import("react").JSX.Element;
25
+ //#endregion
@@ -0,0 +1,65 @@
1
+ import { useAdminMeta } from "./meta-gate.js";
2
+ import { RelationPicker } from "./relation-picker.js";
3
+ import { Token } from "../primitives/token.js";
4
+ import { usePickedLabels } from "./use-picked-labels.js";
5
+ import { useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/record-picker.tsx
8
+ /**
9
+ * Picks records of a resource of the app with the create form's relation picker: type to narrow them by their search
10
+ * fields or title, scroll for more, choose with the mouse or the keyboard. With `multiple`, each choice is added as a
11
+ * token that can be taken off again, and choosing a chosen record takes it off.
12
+ */
13
+ const RecordPicker = (props) => {
14
+ const { resources, views } = useAdminMeta();
15
+ const target = resources[props.resource];
16
+ if (!target) throw new Error(`RecordPicker: the app has no resource named "${props.resource}"`);
17
+ if (target.primaryKey.length !== 1) throw new Error(`RecordPicker: "${props.resource}" has a composite key, which a picker cannot name`);
18
+ const view = views[props.resource];
19
+ const keys = props.multiple ? props.value : props.value === void 0 ? [] : [props.value];
20
+ const [known, setKnown] = useState({});
21
+ const fetched = usePickedLabels(target, view, keys);
22
+ const labelOf = (key) => known[key] ?? fetched.get(key) ?? key;
23
+ const remember = (key, label) => setKnown((current) => ({
24
+ ...current,
25
+ [key]: label
26
+ }));
27
+ if (!props.multiple) {
28
+ const { value, onChange } = props;
29
+ return /* @__PURE__ */ jsx(RelationPicker, {
30
+ target,
31
+ view,
32
+ value: value ?? "",
33
+ label: value === void 0 ? void 0 : labelOf(value),
34
+ invalid: props.invalid,
35
+ onChange: (key, label) => {
36
+ remember(key, label);
37
+ onChange(key);
38
+ }
39
+ });
40
+ }
41
+ const { value, onChange } = props;
42
+ return /* @__PURE__ */ jsxs("div", {
43
+ className: "flex flex-col gap-2",
44
+ children: [/* @__PURE__ */ jsx(RelationPicker, {
45
+ target,
46
+ view,
47
+ value: "",
48
+ chosen: value,
49
+ several: true,
50
+ invalid: props.invalid,
51
+ onChange: (key, label) => {
52
+ remember(key, label);
53
+ onChange(value.includes(key) ? value.filter((chosen) => chosen !== key) : [...value, key]);
54
+ }
55
+ }), value.length > 0 && /* @__PURE__ */ jsx("div", {
56
+ className: "flex flex-wrap gap-1",
57
+ children: value.map((key) => /* @__PURE__ */ jsx(Token, {
58
+ label: labelOf(key),
59
+ onRemove: () => onChange(value.filter((chosen) => chosen !== key))
60
+ }, key))
61
+ })]
62
+ });
63
+ };
64
+ //#endregion
65
+ export { RecordPicker };
@@ -26,7 +26,7 @@ const useLoadMoreInView = (list, sentinel, loaded, loadMore) => {
26
26
  * Picks a record of another resource: type to narrow the records by their search fields or title, scroll for more, and
27
27
  * choose with the mouse or with the arrow keys and Enter; Escape closes the list.
28
28
  */
29
- const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
29
+ const RelationPicker = ({ target, view, value, label, chosen = [value], several = false, invalid, onChange }) => {
30
30
  const [text, setText] = useState("");
31
31
  const [open, setOpen] = useState(false);
32
32
  const [active, setActive] = useState(-1);
@@ -66,7 +66,9 @@ const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
66
66
  };
67
67
  const choose = (item) => {
68
68
  onChange(String(item[keyField]), String(item[labelField]));
69
- close();
69
+ if (!several) return close();
70
+ setText("");
71
+ setActive(-1);
70
72
  };
71
73
  const onKeyDown = (event) => {
72
74
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
@@ -111,6 +113,7 @@ const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
111
113
  ref: setList,
112
114
  id: listId,
113
115
  role: "listbox",
116
+ "aria-multiselectable": several || void 0,
114
117
  className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 shadow-pop",
115
118
  onMouseDown: (event) => event.preventDefault(),
116
119
  children: [
@@ -118,7 +121,7 @@ const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
118
121
  id: optionId(index),
119
122
  role: "option",
120
123
  "aria-selected": index === active,
121
- className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", String(item[keyField]) === value && "font-medium"),
124
+ className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", chosen.includes(String(item[keyField])) && "font-medium"),
122
125
  onMouseMove: () => index !== active && setActive(index),
123
126
  onClick: () => choose(item),
124
127
  children: String(item[labelField])
@@ -0,0 +1,19 @@
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 { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ //#region src/app/use-picked-labels.ts
6
+ /** The names of the chosen records, fetched in one list call; the previous names stay while the next ones load. */
7
+ const usePickedLabels = (target, view, ids) => {
8
+ const client = useClient();
9
+ const queryClient = useQueryClient();
10
+ const sorted = [...new Set(ids)].sort();
11
+ return useQuery({
12
+ queryKey: keys.pickedLabels(target.name, sorted.join(",")),
13
+ enabled: sorted.length > 0,
14
+ placeholderData: keepPreviousData,
15
+ queryFn: () => fetchLabels(queryClient, client, target, sorted, view)
16
+ }).data ?? /* @__PURE__ */ new Map();
17
+ };
18
+ //#endregion
19
+ export { usePickedLabels };
@@ -126,6 +126,12 @@ const keys = {
126
126
  resource,
127
127
  ids
128
128
  ],
129
+ /** The names of the records a `RecordPicker` holds. */
130
+ pickedLabels: (resource, ids) => [
131
+ "picked-labels",
132
+ resource,
133
+ ids
134
+ ],
129
135
  count: (resource, filter) => [
130
136
  "count",
131
137
  resource,
@@ -1,9 +1,10 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { formatInt } from "../format/number.js";
3
+ import { Token } from "../primitives/token.js";
3
4
  import { facetLabel } from "./filter-state.js";
4
5
  import { comboboxOptions, moveActive } from "./combobox-options.js";
5
6
  import { useEffect, useId, useRef, useState } from "react";
6
- import { Check, Search, X } from "lucide-react";
7
+ import { Check, Search } from "lucide-react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  //#region src/filter-panel/facet-combobox.tsx
9
10
  /** Chooses several values of a facet: the choices sit in the box as removable tokens, typing searches the options. */
@@ -50,19 +51,9 @@ const FacetCombobox = ({ config, selected, onToggle, inline = false }) => {
50
51
  onClick: () => input.current?.focus(),
51
52
  children: [
52
53
  /* @__PURE__ */ jsx(Search, { className: "size-3.5 shrink-0 text-muted-foreground" }),
53
- selected.map((value) => /* @__PURE__ */ jsxs("span", {
54
- className: "inline-flex h-5 max-w-full items-center gap-0.5 rounded bg-primary-soft pl-1.5 pr-0.5 text-xs font-medium text-primary-text",
55
- children: [/* @__PURE__ */ jsx("span", {
56
- className: "truncate",
57
- children: facetLabel(config, value)
58
- }), /* @__PURE__ */ jsx("button", {
59
- type: "button",
60
- "aria-label": `Remove ${facetLabel(config, value)}`,
61
- className: "rounded p-0.5 hover:bg-primary-border/60",
62
- onMouseDown: (event) => event.preventDefault(),
63
- onClick: () => onToggle(value),
64
- children: /* @__PURE__ */ jsx(X, { className: "size-3" })
65
- })]
54
+ selected.map((value) => /* @__PURE__ */ jsx(Token, {
55
+ label: facetLabel(config, value),
56
+ onRemove: () => onToggle(value)
66
57
  }, value)),
67
58
  /* @__PURE__ */ jsx("input", {
68
59
  ref: input,
package/dist/index.d.ts CHANGED
@@ -59,5 +59,6 @@ import { Field, FieldProps } from "./record-view/field.js";
59
59
  import { useFieldControl } from "./primitives/field-control.js";
60
60
  import { Section } from "./record-view/section.js";
61
61
  import { Form, FormProps } from "./app/create/form.js";
62
+ import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
62
63
  import { Spinner } from "./primitives/spinner.js";
63
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
64
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
package/dist/index.js CHANGED
@@ -50,4 +50,5 @@ import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
50
50
  import { App } from "./app/app.js";
51
51
  import { RecordLink } from "./app/displays/record-link.js";
52
52
  import { Choices } from "./primitives/choices.js";
53
- export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, Button, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, MenuSelect, OtpInput, PartView, RecordLink, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StepList, Textarea, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
53
+ import { RecordPicker } from "./app/record-picker.js";
54
+ export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, Button, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StepList, Textarea, buttonClass, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.23.1";
2
+ var version = "0.23.3";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,20 @@
1
+ import { X } from "lucide-react";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/primitives/token.tsx
4
+ /** A chosen value as a small token with a button that takes it off; the button keeps the focus where it is. */
5
+ const Token = ({ label, onRemove }) => /* @__PURE__ */ jsxs("span", {
6
+ className: "inline-flex h-5 max-w-full items-center gap-0.5 rounded bg-primary-soft pl-1.5 pr-0.5 text-xs font-medium text-primary-text",
7
+ children: [/* @__PURE__ */ jsx("span", {
8
+ className: "truncate",
9
+ children: label
10
+ }), /* @__PURE__ */ jsx("button", {
11
+ type: "button",
12
+ "aria-label": `Remove ${label}`,
13
+ className: "rounded p-0.5 hover:bg-primary-border/60",
14
+ onMouseDown: (event) => event.preventDefault(),
15
+ onClick: onRemove,
16
+ children: /* @__PURE__ */ jsx(X, { className: "size-3" })
17
+ })]
18
+ });
19
+ //#endregion
20
+ export { Token };
@@ -2,14 +2,15 @@ import "lucide-react";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  //#region src/record-view/record-sidebar.tsx
4
4
  const MetadataList = ({ rows }) => /* @__PURE__ */ jsx("dl", {
5
- className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-2",
5
+ className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2",
6
6
  children: rows.map((row) => /* @__PURE__ */ jsxs("div", {
7
7
  className: "contents",
8
8
  children: [/* @__PURE__ */ jsx("dt", {
9
9
  className: "text-muted-foreground",
10
10
  children: row.label
11
11
  }), /* @__PURE__ */ jsx("dd", {
12
- className: "text-right tabular-nums text-foreground",
12
+ title: row.title,
13
+ className: "truncate text-right tabular-nums text-foreground",
13
14
  children: row.value
14
15
  })]
15
16
  }, row.label))