@protobase/ui 0.21.0 → 0.21.1

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 (35) hide show
  1. package/dist/app/create/create-page.js +12 -4
  2. package/dist/app/create/create-record-form.js +3 -1
  3. package/dist/app/create/form.d.ts +3 -2
  4. package/dist/app/create/form.js +3 -1
  5. package/dist/app/field-editor.js +5 -1
  6. package/dist/app/field-errors.js +70 -0
  7. package/dist/app/pages/blocks/modal-form.js +13 -4
  8. package/dist/app/record-page.js +9 -2
  9. package/dist/app/use-focus-first-invalid.js +16 -0
  10. package/dist/app/use-live-save.js +4 -2
  11. package/dist/app/use-record-editor.js +11 -3
  12. package/dist/data-grid/grid-header.js +2 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.js +2 -1
  15. package/dist/package.js +1 -1
  16. package/dist/primitives/checkbox.js +2 -1
  17. package/dist/primitives/field-control.d.ts +19 -0
  18. package/dist/primitives/field-control.js +21 -0
  19. package/dist/primitives/input.js +17 -11
  20. package/dist/primitives/switch.js +15 -9
  21. package/dist/primitives/textarea.js +4 -0
  22. package/dist/record-view/field.d.ts +7 -1
  23. package/dist/record-view/field.js +83 -56
  24. package/dist/shell/assets/app-DGVgRtZo.js +69 -0
  25. package/dist/shell/assets/{index-iXIDNHEm.js → index-CW037BBI.js} +1 -1
  26. package/dist/shell/assets/{maplibre-DxWag0Wp.js → maplibre-Ck9abcyu.js} +2 -2
  27. package/dist/shell/assets/{markdown-content-Bco0XDqw.js → markdown-content-BDjSKrC7.js} +1 -1
  28. package/dist/shell/assets/{record-map-Ca1fz02Q.js → record-map-CKPZ2KJs.js} +1 -1
  29. package/dist/shell/assets/{records-map-JooppeKw.js → records-map-DadV3GNO.js} +1 -1
  30. package/dist/shell/assets/shared-protobase-ui-tJCYxj7E.js +1 -0
  31. package/dist/shell/index.html +3 -3
  32. package/dist/shell/protobase.shell.json +1 -1
  33. package/package.json +4 -4
  34. package/dist/shell/assets/app-BBx2Bkop.js +0 -69
  35. package/dist/shell/assets/shared-protobase-ui-BZs06oKf.js +0 -1
@@ -7,10 +7,11 @@ import { useAppAddress } from "../use-app-address.js";
7
7
  import { FieldEditor } from "../field-editor.js";
8
8
  import { can } from "../permissions.js";
9
9
  import { fetchLabels } from "../../live/relation-labels.js";
10
+ import { fieldErrors, fieldMessage } from "../field-errors.js";
11
+ import { useFocusFirstInvalid } from "../use-focus-first-invalid.js";
10
12
  import { useLiveSave } from "../use-live-save.js";
11
13
  import { CreateRecordForm } from "./create-record-form.js";
12
14
  import { useEffect, useState } from "react";
13
- import { isApiError } from "@protobase/client";
14
15
  import { jsx, jsxs } from "react/jsx-runtime";
15
16
  import { useQueryClient } from "@tanstack/react-query";
16
17
  //#region src/app/create/create-page.tsx
@@ -53,6 +54,8 @@ const LoadedCreate = ({ resource, values }) => {
53
54
  const sections = createSections(model, view);
54
55
  const [draft, setDraft] = useState(() => initialDraft(model, sections, values));
55
56
  const [errors, setErrors] = useState({});
57
+ const [rejections, setRejections] = useState(0);
58
+ const formRef = useFocusFirstInvalid(rejections);
56
59
  useEffect(() => {
57
60
  for (const [name, key] of Object.entries(values ?? {})) {
58
61
  const target = meta.resources[model.fields[name]?.relation?.resource ?? ""];
@@ -87,7 +90,9 @@ const LoadedCreate = ({ resource, values }) => {
87
90
  try {
88
91
  created = await client.create(resource, createBody(model, meta.resources, draft));
89
92
  } catch (error) {
90
- if (isApiError(error)) setErrors(Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])));
93
+ const found = fieldErrors(error);
94
+ setErrors(found);
95
+ if (Object.keys(found).length > 0) setRejections((count) => count + 1);
91
96
  throw error;
92
97
  }
93
98
  for (const prefix of [
@@ -111,12 +116,14 @@ const LoadedCreate = ({ resource, values }) => {
111
116
  const field = model.fields[name];
112
117
  const value = draft[name];
113
118
  const target = field.relation ? meta.resources[field.relation.resource] : void 0;
119
+ const label = view?.fields[name]?.label ?? humanize(name);
120
+ const problem = errors[name];
114
121
  return {
115
122
  name,
116
- label: view?.fields[name]?.label ?? humanize(name),
123
+ label,
117
124
  help: view?.fields[name]?.help,
118
125
  required: isRequired(field),
119
- error: errors[name],
126
+ error: problem && fieldMessage(field, label, problem),
120
127
  wide: Boolean(view?.fields[name]?.multiline || view?.fields[name]?.markdown),
121
128
  editor: /* @__PURE__ */ jsx(FieldEditor, {
122
129
  field,
@@ -137,6 +144,7 @@ const LoadedCreate = ({ resource, values }) => {
137
144
  })
138
145
  }));
139
146
  return /* @__PURE__ */ jsx(CreateRecordForm, {
147
+ ref: formRef,
140
148
  noun,
141
149
  collection: view?.names?.plural ?? humanize(resource),
142
150
  sections: formSections,
@@ -4,7 +4,8 @@ import { Form } from "./form.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  //#region src/app/create/create-record-form.tsx
6
6
  /** The presentation of "New <thing>": the view's sections, each field with its editor, and Create or Cancel. */
7
- const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel }) => /* @__PURE__ */ jsx(Form, {
7
+ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel, ref }) => /* @__PURE__ */ jsx(Form, {
8
+ ref,
8
9
  title: `New ${noun.toLowerCase()}`,
9
10
  eyebrow: collection,
10
11
  submit: `Create ${noun.toLowerCase()}`,
@@ -19,6 +20,7 @@ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmi
19
20
  children: /* @__PURE__ */ jsx("div", {
20
21
  className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
21
22
  children: section.fields.map((field) => /* @__PURE__ */ jsx(Field, {
23
+ name: field.name,
22
24
  label: field.label,
23
25
  required: field.required,
24
26
  help: field.help,
@@ -1,4 +1,4 @@
1
- import { ReactNode } from "react";
1
+ import { ReactNode, Ref } from "react";
2
2
  //#region src/app/create/form.d.ts
3
3
  export type FormProps = {
4
4
  /**
@@ -24,7 +24,8 @@ export type FormProps = {
24
24
  onCancel?: () => void;
25
25
  /** The form's sections (`Section`), drawn one under the other with a line between them. */
26
26
  children: ReactNode;
27
+ ref?: Ref<HTMLFormElement>;
27
28
  };
28
29
  /** The frame of the app's forms: Cancel and the submit button, a failure that belongs to no field, and the sections. */
29
- export declare const Form: ({ title, eyebrow, submit, saving, disabled, error, errorTitle, onSubmit, onCancel, children }: FormProps) => import("react").JSX.Element;
30
+ export declare const Form: ({ title, eyebrow, submit, saving, disabled, error, errorTitle, onSubmit, onCancel, children, ref }: FormProps) => import("react").JSX.Element;
30
31
  //#endregion
@@ -3,7 +3,7 @@ import { ErrorBanner } from "../error-banner.js";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/app/create/form.tsx
5
5
  /** The frame of the app's forms: Cancel and the submit button, a failure that belongs to no field, and the sections. */
6
- const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "Not saved", onSubmit, onCancel, children }) => {
6
+ const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "Not saved", onSubmit, onCancel, children, ref }) => {
7
7
  const buttons = /* @__PURE__ */ jsxs("div", {
8
8
  className: "flex items-center gap-2",
9
9
  children: [onCancel && /* @__PURE__ */ jsx(Button, {
@@ -35,6 +35,7 @@ const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "N
35
35
  onSubmit();
36
36
  };
37
37
  if (title === void 0) return /* @__PURE__ */ jsxs("form", {
38
+ ref,
38
39
  className: "max-w-[760px]",
39
40
  onSubmit: send,
40
41
  children: [
@@ -47,6 +48,7 @@ const Form = ({ title, eyebrow, submit, saving, disabled, error, errorTitle = "N
47
48
  ]
48
49
  });
49
50
  return /* @__PURE__ */ jsx("form", {
51
+ ref,
50
52
  className: "flex-1",
51
53
  onSubmit: send,
52
54
  children: /* @__PURE__ */ jsxs("div", {
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { useFieldControl } from "../primitives/field-control.js";
2
3
  import { Input } from "../primitives/input.js";
3
4
  import { buttonClass } from "../primitives/button.js";
4
5
  import { humanize } from "../live/naming.js";
@@ -25,6 +26,7 @@ const numeric = /* @__PURE__ */ new Set([
25
26
  "decimal"
26
27
  ]);
27
28
  const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
29
+ const control = useFieldControl();
28
30
  if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
29
31
  field,
30
32
  hints,
@@ -78,7 +80,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
78
80
  if (field.type === "boolean") return /* @__PURE__ */ jsx(Switch, {
79
81
  checked: Boolean(value),
80
82
  onChange,
81
- label: field.name
83
+ label: hints?.label ?? humanize(field.name)
82
84
  });
83
85
  if (field.type === "relation") {
84
86
  const label = relationLabel ?? String(stored ?? "—");
@@ -102,6 +104,8 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
102
104
  children: displayValue(field, stored, hints)
103
105
  });
104
106
  if (field.type === "enum") return /* @__PURE__ */ jsxs("select", {
107
+ ...control,
108
+ ...invalid && { "aria-invalid": true },
105
109
  value: String(value),
106
110
  onChange: (event) => onChange(event.target.value),
107
111
  className: "h-8 w-full rounded-md border border-border-strong bg-background px-2 shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
@@ -0,0 +1,70 @@
1
+ import { formatInt } from "../format/number.js";
2
+ import { isApiError } from "@protobase/client";
3
+ //#region src/app/field-errors.ts
4
+ const numberIn = (params, key) => typeof params?.[key] === "number" ? params[key] : void 0;
5
+ const characters = (count) => `${formatInt(count)} character${count === 1 ? "" : "s"}`;
6
+ const textMessage = (label, params) => {
7
+ const min = numberIn(params, "min");
8
+ const max = numberIn(params, "max");
9
+ if (min !== void 0) return `${label} needs at least ${characters(min)}`;
10
+ if (max !== void 0) return `${label} can have at most ${characters(max)}`;
11
+ };
12
+ const wholeNumber = (params) => {
13
+ const min = numberIn(params, "min");
14
+ const max = numberIn(params, "max");
15
+ if (min !== void 0) return `Enter a whole number of at least ${formatInt(min)}`;
16
+ if (max !== void 0) return `Enter a whole number of at most ${formatInt(max)}`;
17
+ return "Enter a whole number";
18
+ };
19
+ const decimalMessage = (params) => {
20
+ const precision = numberIn(params, "precision");
21
+ const scale = numberIn(params, "scale");
22
+ if (precision === void 0 || scale === void 0) return "Enter a number";
23
+ const below = formatInt(10 ** (precision - scale));
24
+ return scale === 0 ? `Enter a whole number below ${below}` : `Enter a number with at most ${scale} decimal${scale === 1 ? "" : "s"}, below ${below}`;
25
+ };
26
+ /** What to say about a value of each field type the server turned down; without an entry, the server's message shows. */
27
+ const invalidValue = {
28
+ text: textMessage,
29
+ integer: (_, params) => wholeNumber(params),
30
+ bigint: () => "Enter a whole number",
31
+ decimal: (_, params) => decimalMessage(params),
32
+ date: () => "Enter a date",
33
+ timestamp: () => "Enter a date and time",
34
+ country: () => "Enter a two-letter country code, such as NL",
35
+ currency: () => "Enter a three-letter currency code, such as EUR",
36
+ uuid: () => "Enter a valid ID",
37
+ enum: () => "Choose one of the options",
38
+ relation: () => "Choose a record",
39
+ json: () => "Enter valid JSON"
40
+ };
41
+ /**
42
+ * The message a form shows under a field the server turned down, in the words of the field's label: "Country is
43
+ * required", "Enter a number with at most 2 decimals". A `.validate()` rule's message and anything unknown show as the
44
+ * server words them.
45
+ */
46
+ const fieldMessage = (field, label, error) => {
47
+ if (error.code === "required") return `${label} is required`;
48
+ if (error.code !== "invalid-value" || !field) return error.message;
49
+ return invalidValue[field.type]?.(label, error.params) ?? error.message;
50
+ };
51
+ /** The first problem with each field in an API error, by field name; none for any other error, nor for a rule about the whole record. */
52
+ const fieldErrors = (error) => {
53
+ if (!isApiError(error)) return {};
54
+ const found = {};
55
+ for (const entry of error.errors) if (entry.field && entry.field !== "_record" && !Object.hasOwn(found, entry.field)) found[entry.field] = entry;
56
+ return found;
57
+ };
58
+ /**
59
+ * What a save's toast says about an API error: how many fields to check when the fields show what is wrong, besides
60
+ * the messages of rules about the whole record; the error's own message otherwise.
61
+ */
62
+ const saveFailureDescription = (error) => {
63
+ if (error.operation !== void 0) return error.message;
64
+ const fields = new Set(error.errors.flatMap((entry) => entry.field && entry.field !== "_record" ? [entry.field] : []));
65
+ const record = error.errors.flatMap((entry) => entry.field === "_record" ? [entry.message] : []);
66
+ if (fields.size === 0 && record.length === 0) return error.message;
67
+ return [...fields.size === 0 ? [] : [`Check ${fields.size === 1 ? "the marked field" : `${fields.size} fields`}.`], ...record].join(" ");
68
+ };
69
+ //#endregion
70
+ export { fieldErrors, fieldMessage, saveFailureDescription };
@@ -13,8 +13,9 @@ import { useLayoutRecord } from "../record-scope.js";
13
13
  import { can } from "../../permissions.js";
14
14
  import { useFirstRecord, useRefreshResource } from "../use-layout-data.js";
15
15
  import { propsOf } from "./block-props.js";
16
+ import { fieldErrors, fieldMessage } from "../../field-errors.js";
17
+ import { useFocusFirstInvalid } from "../../use-focus-first-invalid.js";
16
18
  import { useState } from "react";
17
- import { isApiError } from "@protobase/client";
18
19
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
19
20
  //#region src/app/pages/blocks/modal-form.tsx
20
21
  /** The literal defaults of new records, or the record's values when editing; relations as `{ key, label }` once chosen. */
@@ -39,6 +40,8 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
39
40
  const view = views[model.name];
40
41
  const [draft, setDraft] = useState(() => startingDraft(model, fields, record));
41
42
  const [errors, setErrors] = useState({});
43
+ const [rejections, setRejections] = useState(0);
44
+ const formRef = useFocusFirstInvalid(rejections);
42
45
  const [failure, setFailure] = useState();
43
46
  const [saving, setSaving] = useState(false);
44
47
  const submit = async () => {
@@ -55,7 +58,9 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
55
58
  });
56
59
  onClose();
57
60
  }, (error) => {
58
- if (isApiError(error)) setErrors(Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])));
61
+ const found = fieldErrors(error);
62
+ setErrors(found);
63
+ if (Object.keys(found).length > 0) setRejections((count) => count + 1);
59
64
  setFailure(error);
60
65
  });
61
66
  setSaving(false);
@@ -73,6 +78,7 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
73
78
  children: mode === "create" ? "Create" : "Save"
74
79
  })] }),
75
80
  children: /* @__PURE__ */ jsxs("form", {
81
+ ref: formRef,
76
82
  className: "flex flex-col gap-4",
77
83
  onSubmit: (event) => {
78
84
  event.preventDefault();
@@ -85,10 +91,13 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
85
91
  const field = model.fields[name];
86
92
  const value = draft[name];
87
93
  const target = field.relation ? resources[field.relation.resource] : void 0;
94
+ const label = view?.fields[name]?.label ?? humanize(name);
95
+ const problem = errors[name];
88
96
  return /* @__PURE__ */ jsx(Field, {
89
- label: view?.fields[name]?.label ?? humanize(name),
97
+ name,
98
+ label,
90
99
  help: view?.fields[name]?.help,
91
- error: errors[name],
100
+ error: problem && fieldMessage(field, label, problem),
92
101
  children: /* @__PURE__ */ jsx(FieldEditor, {
93
102
  field,
94
103
  hints: view?.fields[name],
@@ -34,6 +34,8 @@ import { can, fieldAccess } from "./permissions.js";
34
34
  import { toneFor } from "../live/columns-from-model.js";
35
35
  import { ValueBadge } from "./value-badge.js";
36
36
  import { FieldValue } from "./pages/field-value.js";
37
+ import { fieldMessage } from "./field-errors.js";
38
+ import { useFocusFirstInvalid } from "./use-focus-first-invalid.js";
37
39
  import { namedIcon } from "./named-icon.js";
38
40
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
39
41
  import { useRestore } from "./delete/use-restore.js";
@@ -130,6 +132,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
130
132
  });
131
133
  const queryClient = useQueryClient();
132
134
  const client = useClient();
135
+ const fieldsRef = useFocusFirstInvalid(editor.rejections);
133
136
  const permissions = usePermissions(model.name);
134
137
  const restore = useRestore(model);
135
138
  const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
@@ -192,11 +195,14 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
192
195
  if (!field || access === "hidden") return null;
193
196
  const conflict = editor.conflicts[name];
194
197
  const target = pickTarget(name);
198
+ const label = hint(name)?.label ?? humanize(name);
199
+ const problem = editor.errors[name];
195
200
  return /* @__PURE__ */ jsx(Field, {
196
- label: hint(name)?.label ?? humanize(name),
201
+ name,
202
+ label,
197
203
  required: shown === "form" && access !== "read" && isRequiredOnEdit(field),
198
204
  help: fieldHelp(hint(name), view, editing),
199
- error: editor.errors[name],
205
+ error: problem && fieldMessage(field, label, problem),
200
206
  wide: Boolean(hint(name)?.multiline || hint(name)?.markdown),
201
207
  conflict: conflict === void 0 ? void 0 : {
202
208
  user: {
@@ -323,6 +329,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
323
329
  })
324
330
  ] });
325
331
  return /* @__PURE__ */ jsxs("div", {
332
+ ref: fieldsRef,
326
333
  className: "flex flex-1 flex-col",
327
334
  inert: preview,
328
335
  children: [
@@ -0,0 +1,16 @@
1
+ import { useEffect, useRef } from "react";
2
+ //#region src/app/use-focus-first-invalid.ts
3
+ /**
4
+ * Puts focus on the first control marked invalid inside the element the ref is on, each time `rejections` counts
5
+ * another save the server turned down, once the form shows why.
6
+ */
7
+ const useFocusFirstInvalid = (rejections) => {
8
+ const ref = useRef(null);
9
+ useEffect(() => {
10
+ if (rejections === 0) return;
11
+ ref.current?.querySelector("[aria-invalid=\"true\"]")?.focus();
12
+ }, [rejections]);
13
+ return ref;
14
+ };
15
+ //#endregion
16
+ export { useFocusFirstInvalid };
@@ -1,4 +1,5 @@
1
1
  import { useToast } from "../toasts/toast-provider.js";
2
+ import { saveFailureDescription } from "./field-errors.js";
2
3
  import { useEffect, useRef, useState } from "react";
3
4
  import { isApiError } from "@protobase/client";
4
5
  //#region src/app/use-live-save.ts
@@ -35,14 +36,15 @@ const useLiveSave = (mode, subject) => {
35
36
  } catch (error) {
36
37
  if (!isApiError(error)) throw error;
37
38
  const title = error.status === 412 ? "Changed by someone else" : "Could not save";
39
+ const description = saveFailureDescription(error);
38
40
  if (id !== void 0) toast.update(id, {
39
41
  state: "error",
40
42
  title,
41
- description: error.message
43
+ description
42
44
  });
43
45
  if (mode === "button") {
44
46
  setPhase("idle");
45
- setMessage(`${title}: ${error.message}`);
47
+ setMessage(`${title}: ${description}`);
46
48
  }
47
49
  return false;
48
50
  }
@@ -2,6 +2,7 @@ import { keys } from "../data/query-keys.js";
2
2
  import { useClient } from "../data/api-provider.js";
3
3
  import { differs, toDraft, toPatchValue } from "../live/field-values.js";
4
4
  import { displayValue } from "../live/display-value.js";
5
+ import { fieldErrors } from "./field-errors.js";
5
6
  import { useLiveSave } from "./use-live-save.js";
6
7
  import { readDraft, writeDraft } from "./draft-storage.js";
7
8
  import { isConflict, useUpdateRecord } from "../data/use-update-record.js";
@@ -27,6 +28,7 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
27
28
  const [linesConflict, setLinesConflict] = useState(false);
28
29
  const [conflicts, setConflicts] = useState({});
29
30
  const [errors, setErrors] = useState({});
31
+ const [rejections, setRejections] = useState(0);
30
32
  const changed = Object.entries(draft).filter(([name, entry]) => differs(model.fields[name], entry, stored.record[name]));
31
33
  const fieldsDirty = changed.length > 0;
32
34
  const dirty = fieldsDirty || Boolean(lines?.dirty);
@@ -74,7 +76,11 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
74
76
  setConflicts(theirs);
75
77
  };
76
78
  const patchOf = () => Object.fromEntries(changed.map(([name, entry]) => [name, toPatchValue(model.fields[name], entry)]));
77
- const fieldErrors = (error) => isApiError(error) ? Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])) : {};
79
+ const reject = (error) => {
80
+ const found = fieldErrors(error);
81
+ setErrors(found);
82
+ if (Object.keys(found).length > 0) setRejections((count) => count + 1);
83
+ };
78
84
  const saveRecordOnly = async () => {
79
85
  try {
80
86
  await update.mutateAsync({
@@ -83,7 +89,7 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
83
89
  });
84
90
  } catch (error) {
85
91
  if (isConflict(error)) await adoptLatest();
86
- else if (isApiError(error)) setErrors(fieldErrors(error));
92
+ else if (isApiError(error)) reject(error);
87
93
  throw error;
88
94
  }
89
95
  };
@@ -102,7 +108,7 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
102
108
  await adoptLatest();
103
109
  await queryClient.invalidateQueries({ queryKey: ["list"] });
104
110
  setLinesConflict(true);
105
- } else if (isApiError(error)) setErrors(fieldErrors(error));
111
+ } else if (isApiError(error)) reject(error);
106
112
  throw isApiError(error) ? nameFailingOperation(error, ops) : error;
107
113
  }
108
114
  await Promise.all([
@@ -146,7 +152,9 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
146
152
  phase: feedback.phase,
147
153
  message: feedback.message,
148
154
  conflicts,
155
+ /** The server's problem with each field, from the last save. */
149
156
  errors,
157
+ rejections,
150
158
  keepMine: (name) => setConflicts((current) => without(current, name)),
151
159
  useTheirs: (name) => {
152
160
  setConflicts((current) => without(current, name));
@@ -22,7 +22,8 @@ const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing,
22
22
  children: [selectable && /* @__PURE__ */ jsx(Checkbox, {
23
23
  checked: false,
24
24
  indeterminate: selectedCount > 0,
25
- label: "Clear selection",
25
+ label: selectedCount > 0 ? "Clear selection" : "No rows selected",
26
+ disabled: selectedCount === 0,
26
27
  onChange: onClearSelection,
27
28
  className: cn(selectedCount === 0 && "opacity-60")
28
29
  }), gutter > 40 && /* @__PURE__ */ jsx("span", {
package/dist/index.d.ts CHANGED
@@ -56,7 +56,8 @@ import { MenuSelect, MenuSelectProps } from "./primitives/menu-select.js";
56
56
  import { ChoiceOption, Choices, ChoicesProps } from "./primitives/choices.js";
57
57
  import { Dialog, DialogProps } from "./primitives/dialog.js";
58
58
  import { Field, FieldProps } from "./record-view/field.js";
59
+ import { useFieldControl } from "./primitives/field-control.js";
59
60
  import { Section } from "./record-view/section.js";
60
61
  import { Form, FormProps } from "./app/create/form.js";
61
62
  import { Spinner } from "./primitives/spinner.js";
62
- 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, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
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 };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LogoMark } from "./app-shell/logo.js";
2
2
  import { Segmented } from "./primitives/segmented.js";
3
+ import { useFieldControl } from "./primitives/field-control.js";
3
4
  import { Input } from "./primitives/input.js";
4
5
  import { Spinner } from "./primitives/spinner.js";
5
6
  import { Button, buttonClass } from "./primitives/button.js";
@@ -49,4 +50,4 @@ import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
49
50
  import { App } from "./app/app.js";
50
51
  import { RecordLink } from "./app/displays/record-link.js";
51
52
  import { Choices } from "./primitives/choices.js";
52
- 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, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
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 };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.21.0";
2
+ var version = "0.21.1";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -2,11 +2,12 @@ import { cn } from "../lib/cn.js";
2
2
  import { Check, Minus } from "lucide-react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
  //#region src/primitives/checkbox.tsx
5
- const Checkbox = ({ checked, indeterminate, onChange, label, className }) => /* @__PURE__ */ jsx("button", {
5
+ const Checkbox = ({ checked, indeterminate, onChange, label, disabled, className }) => /* @__PURE__ */ jsx("button", {
6
6
  type: "button",
7
7
  role: "checkbox",
8
8
  "aria-checked": indeterminate ? "mixed" : checked,
9
9
  "aria-label": label,
10
+ disabled,
10
11
  onClick: () => onChange?.(!checked),
11
12
  className: cn("relative inline-flex size-4 shrink-0 items-center justify-center rounded border transition-colors after:absolute after:-inset-1 after:content-[\"\"] pointer-coarse:after:-inset-3.5", checked || indeterminate ? "border-primary bg-primary text-primary-foreground" : "border-border-strong bg-background hover:border-faint-foreground", className),
12
13
  children: indeterminate ? /* @__PURE__ */ jsx(Minus, {
@@ -0,0 +1,19 @@
1
+ //#region src/primitives/field-control.d.ts
2
+ /**
3
+ * The attributes that tie a control to the `Field` around it: the id its label points at, its name, the help and error
4
+ * that describe it, and whether it is invalid or required. None outside a `Field`.
5
+ */
6
+ export declare const useFieldControl: () => {
7
+ id?: undefined;
8
+ name?: undefined;
9
+ 'aria-describedby'?: undefined;
10
+ 'aria-invalid'?: undefined;
11
+ 'aria-required'?: undefined;
12
+ } | {
13
+ id: string;
14
+ name: string | undefined;
15
+ 'aria-describedby': string | undefined;
16
+ 'aria-invalid': true | undefined;
17
+ 'aria-required': true | undefined;
18
+ };
19
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext } from "react";
2
+ //#region src/primitives/field-control.ts
3
+ /** Set by a form's `Field` for the control inside it. */
4
+ const FieldControlContext = createContext(void 0);
5
+ /**
6
+ * The attributes that tie a control to the `Field` around it: the id its label points at, its name, the help and error
7
+ * that describe it, and whether it is invalid or required. None outside a `Field`.
8
+ */
9
+ const useFieldControl = () => {
10
+ const control = useContext(FieldControlContext);
11
+ if (!control) return {};
12
+ return {
13
+ id: control.id,
14
+ name: control.name,
15
+ "aria-describedby": control.describedBy,
16
+ "aria-invalid": control.invalid || void 0,
17
+ "aria-required": control.required || void 0
18
+ };
19
+ };
20
+ //#endregion
21
+ export { FieldControlContext, useFieldControl };
@@ -1,16 +1,22 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { useFieldControl } from "./field-control.js";
2
3
  import { jsx, jsxs } from "react/jsx-runtime";
3
4
  //#region src/primitives/input.tsx
4
- const Input = ({ leading, trailing, invalid, className, wrapperClassName, ...rest }) => /* @__PURE__ */ jsxs("span", {
5
- className: cn("flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-muted-foreground shadow-sm focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20", invalid ? "border-danger" : "border-border-strong", wrapperClassName),
6
- children: [
7
- leading,
8
- /* @__PURE__ */ jsx("input", {
9
- className: cn("min-w-0 flex-1 bg-transparent text-body text-foreground outline-none placeholder:text-faint-foreground", className),
10
- ...rest
11
- }),
12
- trailing
13
- ]
14
- });
5
+ const Input = ({ leading, trailing, invalid, className, wrapperClassName, ...rest }) => {
6
+ const control = useFieldControl();
7
+ return /* @__PURE__ */ jsxs("span", {
8
+ className: cn("flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-muted-foreground shadow-sm focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20", invalid ? "border-danger" : "border-border-strong", wrapperClassName),
9
+ children: [
10
+ leading,
11
+ /* @__PURE__ */ jsx("input", {
12
+ className: cn("min-w-0 flex-1 bg-transparent text-body text-foreground outline-none placeholder:text-faint-foreground", className),
13
+ ...control,
14
+ ...invalid && { "aria-invalid": true },
15
+ ...rest
16
+ }),
17
+ trailing
18
+ ]
19
+ });
20
+ };
15
21
  //#endregion
16
22
  export { Input };
@@ -1,14 +1,20 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { useFieldControl } from "./field-control.js";
2
3
  import { jsx } from "react/jsx-runtime";
3
4
  //#region src/primitives/switch.tsx
4
- const Switch = ({ checked, onChange, label }) => /* @__PURE__ */ jsx("button", {
5
- type: "button",
6
- role: "switch",
7
- "aria-checked": checked,
8
- "aria-label": label,
9
- onClick: () => onChange?.(!checked),
10
- className: cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors", checked ? "bg-primary" : "bg-border-strong"),
11
- children: /* @__PURE__ */ jsx("span", { className: cn("size-4 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[18px]" : "translate-x-0.5") })
12
- });
5
+ const Switch = ({ checked, onChange, label }) => {
6
+ const control = useFieldControl();
7
+ return /* @__PURE__ */ jsx("button", {
8
+ type: "button",
9
+ role: "switch",
10
+ id: control.id,
11
+ "aria-describedby": control["aria-describedby"],
12
+ "aria-checked": checked,
13
+ "aria-label": label,
14
+ onClick: () => onChange?.(!checked),
15
+ className: cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors", checked ? "bg-primary" : "bg-border-strong"),
16
+ children: /* @__PURE__ */ jsx("span", { className: cn("size-4 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[18px]" : "translate-x-0.5") })
17
+ });
18
+ };
13
19
  //#endregion
14
20
  export { Switch };
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { useFieldControl } from "./field-control.js";
2
3
  import { useEffect, useLayoutEffect, useRef } from "react";
3
4
  import { jsx } from "react/jsx-runtime";
4
5
  //#region src/primitives/textarea.tsx
@@ -9,6 +10,7 @@ const fit = (box) => {
9
10
  /** A text box of several lines that grows with its text instead of scrolling inside. */
10
11
  const Textarea = ({ invalid, className, rows = 3, value, ...rest }) => {
11
12
  const ref = useRef(null);
13
+ const control = useFieldControl();
12
14
  useLayoutEffect(() => {
13
15
  if (ref.current) fit(ref.current);
14
16
  }, [value]);
@@ -29,6 +31,8 @@ const Textarea = ({ invalid, className, rows = 3, value, ...rest }) => {
29
31
  rows,
30
32
  value,
31
33
  className: cn("block w-full resize-none overflow-hidden rounded-md border bg-background px-2.5 py-1.5 text-body text-foreground shadow-sm outline-none placeholder:text-faint-foreground focus:border-primary focus:ring-2 focus:ring-primary/20", invalid ? "border-danger" : "border-border-strong", className),
34
+ ...control,
35
+ ...invalid && { "aria-invalid": true },
32
36
  ...rest
33
37
  });
34
38
  };
@@ -12,6 +12,8 @@ export type FieldConflict = {
12
12
  onUseTheirs: () => void;
13
13
  };
14
14
  export type FieldProps = {
15
+ /** The control's name, for the browser's autofill. */
16
+ name?: string;
15
17
  label: string;
16
18
  /** Marks the label with an asterisk. */
17
19
  required?: boolean;
@@ -23,5 +25,9 @@ export type FieldProps = {
23
25
  wide?: boolean;
24
26
  children: ReactNode;
25
27
  };
26
- export declare const Field: ({ label, required, help, example, error, conflict, wide, children }: FieldProps) => import("react").JSX.Element;
28
+ /**
29
+ * A labelled form field. The `Input` or `Textarea` inside takes its label, its name, the help and error that describe
30
+ * it and whether it is invalid or required from the field; another control uses `useFieldControl`.
31
+ */
32
+ export declare const Field: ({ name, label, required, help, example, error, conflict, wide, children }: FieldProps) => import("react").JSX.Element;
27
33
  //#endregion