@protobase/ui 0.20.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 (45) 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/create-values.js +2 -1
  4. package/dist/app/create/form.d.ts +3 -2
  5. package/dist/app/create/form.js +3 -1
  6. package/dist/app/field-editor.js +34 -12
  7. package/dist/app/field-errors.js +70 -0
  8. package/dist/app/history/describe-change.js +44 -0
  9. package/dist/app/history/history-section.js +92 -0
  10. package/dist/app/history/use-record-history.js +34 -0
  11. package/dist/app/pages/blocks/modal-form.js +13 -4
  12. package/dist/app/record-page.js +52 -9
  13. package/dist/app/use-focus-first-invalid.js +16 -0
  14. package/dist/app/use-live-save.js +4 -2
  15. package/dist/app/use-record-editor.js +11 -3
  16. package/dist/data/api-provider.d.ts +8 -0
  17. package/dist/data/query-keys.js +8 -0
  18. package/dist/data-grid/grid-header.js +2 -1
  19. package/dist/format/date.js +15 -2
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.js +2 -1
  22. package/dist/live/field-values.js +1 -0
  23. package/dist/package.js +1 -1
  24. package/dist/primitives/checkbox.js +2 -1
  25. package/dist/primitives/field-control.d.ts +19 -0
  26. package/dist/primitives/field-control.js +21 -0
  27. package/dist/primitives/input.js +17 -11
  28. package/dist/primitives/switch.js +15 -9
  29. package/dist/primitives/textarea.js +4 -0
  30. package/dist/record-view/field.d.ts +7 -1
  31. package/dist/record-view/field.js +83 -56
  32. package/dist/shell/assets/app-DGVgRtZo.js +69 -0
  33. package/dist/shell/assets/{index-B0veahji.js → index-CW037BBI.js} +1 -1
  34. package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
  35. package/dist/shell/assets/{maplibre-CbjT6RKG.js → maplibre-Ck9abcyu.js} +2 -2
  36. package/dist/shell/assets/{markdown-content-BjRGtSOA.js → markdown-content-BDjSKrC7.js} +1 -1
  37. package/dist/shell/assets/{record-map-rmkTnAs8.js → record-map-CKPZ2KJs.js} +1 -1
  38. package/dist/shell/assets/{records-map-D1vLGY06.js → records-map-DadV3GNO.js} +1 -1
  39. package/dist/shell/assets/shared-protobase-ui-tJCYxj7E.js +1 -0
  40. package/dist/shell/index.html +4 -4
  41. package/dist/shell/protobase.shell.json +1 -1
  42. package/package.json +4 -4
  43. package/dist/shell/assets/app-B5h_q_JL.js +0 -69
  44. package/dist/shell/assets/index-BZmb28OT.css +0 -2
  45. package/dist/shell/assets/shared-protobase-ui-PnkJqBqB.js +0 -1
@@ -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));
@@ -16,6 +16,10 @@ export declare const useClient: () => {
16
16
  }) => Promise<void>;
17
17
  undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
18
18
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
19
+ history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
20
+ pageSize?: number;
21
+ pageToken?: string;
22
+ }) => Promise<import("@protobase/client").HistoryPage>;
19
23
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
20
24
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
21
25
  filter?: import("@protobase/client").FilterInput;
@@ -57,6 +61,10 @@ export declare const useResourceClient: (resource: string) => {
57
61
  }) => Promise<void>;
58
62
  undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
59
63
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
64
+ history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
65
+ pageSize?: number;
66
+ pageToken?: string;
67
+ }) => Promise<import("@protobase/client").HistoryPage>;
60
68
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
61
69
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
62
70
  filter?: import("@protobase/client").FilterInput;
@@ -58,6 +58,14 @@ const keys = {
58
58
  resource,
59
59
  key
60
60
  ],
61
+ /** A record's changes; starts with `record`, so refreshing the record refreshes them too. */
62
+ history: (resource, key, pageSize) => [
63
+ "record",
64
+ resource,
65
+ key,
66
+ "history",
67
+ pageSize
68
+ ],
61
69
  facets: (resource, field, filter) => [
62
70
  "facets",
63
71
  resource,
@@ -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", {
@@ -25,6 +25,19 @@ const formatAgo = (seconds) => {
25
25
  if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
26
26
  return `${Math.floor(seconds / 86400)} d ago`;
27
27
  };
28
- new Intl.RelativeTimeFormat("en", { numeric: "auto" });
28
+ const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
29
+ const units = [
30
+ ["year", 31536e3],
31
+ ["month", 2592e3],
32
+ ["day", 86400],
33
+ ["hour", 3600],
34
+ ["minute", 60]
35
+ ];
36
+ /** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
37
+ const formatRelativeTime = (seconds) => {
38
+ const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
39
+ if (unit === "second" && seconds < 45) return "just now";
40
+ return relative.format(-Math.floor(seconds / size), unit);
41
+ };
29
42
  //#endregion
30
- export { formatAgo, formatDate, formatDateTime, formatDayShort };
43
+ export { formatAgo, formatDate, formatDateTime, formatDayShort, formatRelativeTime };
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 };
@@ -21,6 +21,7 @@ const toPatchValue = (field, draft) => {
21
21
  const text = String(draft).trim();
22
22
  if (text === "") return field.nullable ? null : "";
23
23
  if (field.type === "integer") return Number(text);
24
+ if (field.type === "relation") return typeof draft === "number" ? draft : text;
24
25
  if (textEdited.has(field.type) || field.type === "enum") return text;
25
26
  throw new Error(`Not implemented: editing ${field.type} fields`);
26
27
  };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.20.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
@@ -1,65 +1,92 @@
1
1
  import { Avatar } from "../primitives/avatar.js";
2
+ import { FieldControlContext } from "../primitives/field-control.js";
2
3
  import { Button } from "../primitives/button.js";
4
+ import { useId } from "react";
3
5
  import { TriangleAlert } from "lucide-react";
4
6
  import { jsx, jsxs } from "react/jsx-runtime";
5
7
  //#region src/record-view/field.tsx
6
- const Field = ({ label, required, help, example, error, conflict, wide, children }) => /* @__PURE__ */ jsxs("div", {
7
- className: wide ? "sm:col-span-2" : void 0,
8
- children: [
9
- /* @__PURE__ */ jsxs("div", {
10
- className: "mb-1.5 flex items-center gap-2",
11
- children: [/* @__PURE__ */ jsxs("label", {
12
- className: "font-medium text-foreground",
13
- children: [label, required && /* @__PURE__ */ jsx("span", {
14
- className: "ml-0.5 text-danger-text",
15
- "aria-label": "required",
16
- children: "*"
8
+ /**
9
+ * A labelled form field. The `Input` or `Textarea` inside takes its label, its name, the help and error that describe
10
+ * it and whether it is invalid or required from the field; another control uses `useFieldControl`.
11
+ */
12
+ const Field = ({ name, label, required, help, example, error, conflict, wide, children }) => {
13
+ const id = useId();
14
+ const describedBy = [
15
+ help && `${id}-help`,
16
+ example && `${id}-example`,
17
+ error && `${id}-error`
18
+ ].filter(Boolean).join(" ") || void 0;
19
+ return /* @__PURE__ */ jsxs("div", {
20
+ className: wide ? "sm:col-span-2" : void 0,
21
+ children: [
22
+ /* @__PURE__ */ jsxs("div", {
23
+ className: "mb-1.5 flex items-center gap-2",
24
+ children: [/* @__PURE__ */ jsxs("label", {
25
+ htmlFor: id,
26
+ className: "font-medium text-foreground",
27
+ children: [label, required && /* @__PURE__ */ jsx("span", {
28
+ className: "ml-0.5 text-danger-text",
29
+ "aria-hidden": "true",
30
+ children: "*"
31
+ })]
32
+ }), conflict && /* @__PURE__ */ jsxs("span", {
33
+ className: "inline-flex items-center gap-1.5 text-warning-text",
34
+ title: `${conflict.user.name} saved a different value`,
35
+ children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "size-3.5" }), /* @__PURE__ */ jsx(Avatar, {
36
+ initials: conflict.user.initials,
37
+ hue: conflict.user.hue,
38
+ size: "xs"
39
+ })]
17
40
  })]
18
- }), conflict && /* @__PURE__ */ jsxs("span", {
19
- className: "inline-flex items-center gap-1.5 text-warning-text",
20
- title: `${conflict.user.name} saved a different value`,
21
- children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "size-3.5" }), /* @__PURE__ */ jsx(Avatar, {
22
- initials: conflict.user.initials,
23
- hue: conflict.user.hue,
24
- size: "xs"
41
+ }),
42
+ /* @__PURE__ */ jsx(FieldControlContext, {
43
+ value: {
44
+ id,
45
+ name,
46
+ describedBy,
47
+ invalid: Boolean(error),
48
+ required: Boolean(required)
49
+ },
50
+ children
51
+ }),
52
+ help && /* @__PURE__ */ jsx("p", {
53
+ id: `${id}-help`,
54
+ className: "mt-1.5 text-xs text-muted-foreground",
55
+ children: help
56
+ }),
57
+ example && /* @__PURE__ */ jsxs("p", {
58
+ id: `${id}-example`,
59
+ className: "mt-0.5 text-xs text-faint-foreground",
60
+ children: ["Example: ", example]
61
+ }),
62
+ error && /* @__PURE__ */ jsx("p", {
63
+ id: `${id}-error`,
64
+ className: "mt-1.5 text-xs font-medium text-danger-text",
65
+ children: error
66
+ }),
67
+ conflict && /* @__PURE__ */ jsxs("div", {
68
+ className: "mt-2 flex items-center justify-between gap-3 rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-xs text-warning-text",
69
+ children: [/* @__PURE__ */ jsxs("span", { children: [
70
+ /* @__PURE__ */ jsx("strong", { children: conflict.user.name }),
71
+ " saved this ",
72
+ conflict.ago,
73
+ ": ",
74
+ /* @__PURE__ */ jsx("strong", { children: conflict.theirValue })
75
+ ] }), /* @__PURE__ */ jsxs("span", {
76
+ className: "flex gap-1.5",
77
+ children: [/* @__PURE__ */ jsx(Button, {
78
+ size: "sm",
79
+ onClick: conflict.onKeepMine,
80
+ children: "Keep mine"
81
+ }), /* @__PURE__ */ jsx(Button, {
82
+ size: "sm",
83
+ onClick: conflict.onUseTheirs,
84
+ children: "Use theirs"
85
+ })]
25
86
  })]
26
- })]
27
- }),
28
- children,
29
- help && /* @__PURE__ */ jsx("p", {
30
- className: "mt-1.5 text-xs text-muted-foreground",
31
- children: help
32
- }),
33
- example && /* @__PURE__ */ jsxs("p", {
34
- className: "mt-0.5 text-xs text-faint-foreground",
35
- children: ["Example: ", example]
36
- }),
37
- error && /* @__PURE__ */ jsx("p", {
38
- className: "mt-1.5 text-xs font-medium text-danger-text",
39
- children: error
40
- }),
41
- conflict && /* @__PURE__ */ jsxs("div", {
42
- className: "mt-2 flex items-center justify-between gap-3 rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-xs text-warning-text",
43
- children: [/* @__PURE__ */ jsxs("span", { children: [
44
- /* @__PURE__ */ jsx("strong", { children: conflict.user.name }),
45
- " saved this ",
46
- conflict.ago,
47
- ": ",
48
- /* @__PURE__ */ jsx("strong", { children: conflict.theirValue })
49
- ] }), /* @__PURE__ */ jsxs("span", {
50
- className: "flex gap-1.5",
51
- children: [/* @__PURE__ */ jsx(Button, {
52
- size: "sm",
53
- onClick: conflict.onKeepMine,
54
- children: "Keep mine"
55
- }), /* @__PURE__ */ jsx(Button, {
56
- size: "sm",
57
- onClick: conflict.onUseTheirs,
58
- children: "Use theirs"
59
- })]
60
- })]
61
- })
62
- ]
63
- });
87
+ })
88
+ ]
89
+ });
90
+ };
64
91
  //#endregion
65
92
  export { Field };