@protobase/ui 0.21.7 → 0.21.8

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 (50) hide show
  1. package/dist/app/comments/comment-body.js +13 -0
  2. package/dist/app/comments/comment-composer.js +149 -0
  3. package/dist/app/comments/comments-section.js +78 -0
  4. package/dist/app/comments/mention-text.js +45 -0
  5. package/dist/app/comments/use-mentionable.js +18 -0
  6. package/dist/app/comments/use-post-comment.js +15 -0
  7. package/dist/app/comments/use-record-comments.js +24 -0
  8. package/dist/app/current-address.d.ts +4 -3
  9. package/dist/app/current-address.js +1 -0
  10. package/dist/app/displays/calendar-controls.js +69 -0
  11. package/dist/app/displays/calendar-day.js +33 -0
  12. package/dist/app/displays/calendar-display.js +174 -0
  13. package/dist/app/displays/calendar-dnd.js +27 -0
  14. package/dist/app/displays/calendar-event.js +72 -0
  15. package/dist/app/displays/calendar-range.js +125 -0
  16. package/dist/app/displays/list-displays.js +7 -1
  17. package/dist/app/displays/use-calendar-move.js +68 -0
  18. package/dist/app/displays/use-calendar-rows.js +35 -0
  19. package/dist/app/embedded-list/embedded-list.js +25 -21
  20. package/dist/app/embedded-list/embedded-row-targets.js +65 -0
  21. package/dist/app/list-page.js +16 -5
  22. package/dist/app/pages/use-run-action.js +8 -6
  23. package/dist/app/record-page.js +24 -10
  24. package/dist/app/related-opens.js +22 -0
  25. package/dist/app/related-section.js +4 -1
  26. package/dist/app/use-app-address.d.ts +3 -1
  27. package/dist/app/use-app-address.js +8 -9
  28. package/dist/auth/user-to-shell.js +3 -1
  29. package/dist/data/api-provider.d.ts +20 -0
  30. package/dist/data/query-keys.js +15 -0
  31. package/dist/data-grid/row-menu.js +8 -1
  32. package/dist/format/date.js +27 -1
  33. package/dist/package.js +1 -1
  34. package/dist/primitives/segmented.js +8 -3
  35. package/dist/primitives/textarea.d.ts +4 -3
  36. package/dist/primitives/textarea.js +9 -4
  37. package/dist/shell/assets/app-C01lazBK.js +69 -0
  38. package/dist/shell/assets/{export-worker-CVd5saLq.js → export-worker-BnOpS_4Q.js} +1 -1
  39. package/dist/shell/assets/index-CBGigFxb.css +2 -0
  40. package/dist/shell/assets/{index-CQc-60lQ.js → index-xN6_oZU2.js} +1 -1
  41. package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-Ba9RapjY.js} +1 -1
  42. package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-BUxrbovf.js} +1 -1
  43. package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-14zotZ2s.js} +1 -1
  44. package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-wi-0pXqF.js} +1 -1
  45. package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-BVwhKZk9.js} +1 -1
  46. package/dist/shell/index.html +4 -4
  47. package/dist/shell/protobase.shell.json +1 -1
  48. package/package.json +4 -4
  49. package/dist/shell/assets/app-BbVdHzEY.js +0 -69
  50. package/dist/shell/assets/index-C1fzTv_X.css +0 -2
@@ -49,6 +49,7 @@ import { useLines } from "./use-lines.js";
49
49
  import { useLinesDraft } from "./use-lines-draft.js";
50
50
  import { GallerySection } from "./gallery/gallery-section.js";
51
51
  import { HistorySection } from "./history/history-section.js";
52
+ import { CommentsSection } from "./comments/comments-section.js";
52
53
  import { useRecordEditor } from "./use-record-editor.js";
53
54
  import { useRelatedRecords } from "./use-related-records.js";
54
55
  import { RelatedSection } from "./related-section.js";
@@ -62,21 +63,28 @@ import { enumLabel } from "@protobase/schema";
62
63
  const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
63
64
  /**
64
65
  * The view's layout, or one section with the editable fields when the view has none, then History when the resource
65
- * keeps it. The read view, which is all a user who may only read the resource gets, shows every field: the record rather
66
- * than what can be changed.
66
+ * keeps it and Comments when it takes them. The read view, which is all a user who may only read the resource gets,
67
+ * shows every field: the record rather than what can be changed.
67
68
  */
68
69
  const layoutFor = (model, view, read) => {
69
70
  if (view && view.layout.length > 0) return view.layout;
70
71
  const shown = Object.values(model.fields).filter((field) => !hidden(model, field.name));
71
72
  const editable = shown.filter((field) => !field.readOnly);
72
- return [{
73
- kind: "section",
74
- title: "Details",
75
- fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
76
- }, ...model.history ? [{
77
- kind: "history",
78
- title: "History"
79
- }] : []];
73
+ return [
74
+ {
75
+ kind: "section",
76
+ title: "Details",
77
+ fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
78
+ },
79
+ ...model.history ? [{
80
+ kind: "history",
81
+ title: "History"
82
+ }] : [],
83
+ ...model.comments ? [{
84
+ kind: "comments",
85
+ title: "Comments"
86
+ }] : []
87
+ ];
80
88
  };
81
89
  const RecordPage = ({ resource, recordKey }) => {
82
90
  const { resources } = useAdminMeta();
@@ -424,6 +432,12 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
424
432
  recordKey,
425
433
  etag: stored.etag
426
434
  }, `history:${index}`)];
435
+ if (item.kind === "comments") return [/* @__PURE__ */ jsx(CommentsSection, {
436
+ item,
437
+ resource: model.name,
438
+ recordKey,
439
+ canWrite: auth.kind !== "visitor"
440
+ }, `comments:${index}`)];
427
441
  if (item.kind !== "section") return [];
428
442
  const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
429
443
  const facts = shown === "read" && item.presentation === "facts";
@@ -0,0 +1,22 @@
1
+ //#region src/app/related-opens.ts
2
+ const opensTo = (model, name, resources) => {
3
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
4
+ return field?.type === "relation" && field.relation !== void 0 && resources[field.relation.resource]?.primaryKey.length === 1;
5
+ };
6
+ /**
7
+ * The relation field whose record a related section's row opens: `item.open`, else the one relation in the listed
8
+ * resource's primary key besides `item.field`, when there is exactly one; none when the rows are records in their own right.
9
+ */
10
+ const relatedOpens = (item, model, resources) => {
11
+ if (item.open !== void 0) return item.open !== item.field && opensTo(model, item.open, resources) ? item.open : void 0;
12
+ const candidates = model.primaryKey.filter((name) => name !== item.field && name !== model.tenant && opensTo(model, name, resources));
13
+ return candidates.length === 1 ? candidates[0] : void 0;
14
+ };
15
+ /** The key of the record a row's relation `name` points at; a list row holds a shown relation as `{ id, name }`. Undefined when empty. */
16
+ const relationTarget = (row, name) => {
17
+ const value = row[name];
18
+ const id = typeof value === "object" && value !== null && "id" in value ? value.id : value;
19
+ return typeof id === "string" || typeof id === "number" ? id : void 0;
20
+ };
21
+ //#endregion
22
+ export { relatedOpens, relationTarget };
@@ -6,6 +6,7 @@ import { ErrorBanner } from "./error-banner.js";
6
6
  import { listColumnNames } from "../live/columns-from-model.js";
7
7
  import { useAdminMeta, usePermissions } from "./meta-gate.js";
8
8
  import { searchAll } from "../live/search-all.js";
9
+ import { relatedOpens } from "./related-opens.js";
9
10
  import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
10
11
  import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
11
12
  import { relatedHandOrder } from "./hand-order/hand-order.js";
@@ -82,6 +83,7 @@ const RelatedSection = ({ item, recordKey, owner }) => {
82
83
  const permissions = usePermissions(item.resource);
83
84
  const [sorting, setSorting] = useState(false);
84
85
  if (!model) return null;
86
+ const opens = relatedOpens(item, model, resources);
85
87
  const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
86
88
  const handOrder = relatedHandOrder(item, model, permissions);
87
89
  const displaySwitch = /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
@@ -124,7 +126,8 @@ const RelatedSection = ({ item, recordKey, owner }) => {
124
126
  columns,
125
127
  limit: item.limit ?? 10,
126
128
  display,
127
- empty: item.empty
129
+ empty: item.empty,
130
+ opens
128
131
  }, match.filter)
129
132
  });
130
133
  };
@@ -3,13 +3,15 @@ import { AnchorHTMLAttributes } from "react";
3
3
  import { AppAddress } from "@protobase/schema";
4
4
  //#region src/app/use-app-address.d.ts
5
5
  /**
6
- * The app's address as logical values, and the ways to change it: `set` changes the list in place, `go` moves to
6
+ * The app's address as logical values, and the ways to change it: `set` changes the list in place, `listTo` is the
7
+ * list after a change for a link to it, `go` moves to
7
8
  * another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
8
9
  * writes the address through this hook, never the query string or a path of its own.
9
10
  */
10
11
  export declare const useAppAddress: () => {
11
12
  address: CurrentAddress | undefined;
12
13
  set: (change: ListChange) => void;
14
+ listTo: (change: ListChange) => AppAddress;
13
15
  go: (to: AppAddress, options?: {
14
16
  replace?: boolean;
15
17
  }) => void;
@@ -7,7 +7,8 @@ import { jsx } from "react/jsx-runtime";
7
7
  import { appPath, readAppPath } from "@protobase/schema";
8
8
  //#region src/app/use-app-address.tsx
9
9
  /**
10
- * The app's address as logical values, and the ways to change it: `set` changes the list in place, `go` moves to
10
+ * The app's address as logical values, and the ways to change it: `set` changes the list in place, `listTo` is the
11
+ * list after a change for a link to it, `go` moves to
11
12
  * another address, `href` is an address for an `<a href>` and `open` follows such an href. Every component reads and
12
13
  * writes the address through this hook, never the query string or a path of its own.
13
14
  */
@@ -32,16 +33,14 @@ const useAppAddress = () => {
32
33
  ]);
33
34
  const address = useMemo(() => currentAddress(read, defaults), [read, defaults]);
34
35
  const go = useCallback((to, options) => navigate(appPath(to), options), [navigate]);
36
+ const listTo = useCallback((change) => {
37
+ if (read?.kind !== "list") throw new Error("listTo and set change the list the app shows; use go to move to another address");
38
+ return changedList(read, change, defaults(read.resource));
39
+ }, [read, defaults]);
35
40
  return {
36
41
  address,
37
- set: useCallback((change) => {
38
- if (read?.kind !== "list") throw new Error("set changes the list the app shows; use go to move to another address");
39
- go(changedList(read, change, defaults(read.resource)), { replace: true });
40
- }, [
41
- read,
42
- defaults,
43
- go
44
- ]),
42
+ set: useCallback((change) => go(listTo(change), { replace: true }), [listTo, go]),
43
+ listTo,
45
44
  go,
46
45
  href: useCallback((to) => appPath(to, basePath), [basePath]),
47
46
  open: useCallback((target) => navigate(target.startsWith(`${basePath}/`) ? target.slice(basePath.length) : target), [basePath, navigate])
@@ -1,5 +1,7 @@
1
1
  //#region src/auth/user-to-shell.ts
2
+ /** The hue of someone's avatar, the same for the same id. */
2
3
  const hueOf = (text) => [...text].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) % 360, 7);
4
+ /** The initials of someone's avatar: the first letters of the first two words of their name. */
3
5
  const initialsOf = (name) => name.split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join("");
4
6
  const rolesOf = (user) => (user.role ?? "user").split(",").map((role) => role.trim());
5
7
  /** The user holds the `admin` role, among others perhaps. */
@@ -18,4 +20,4 @@ const userToShell = (user) => {
18
20
  };
19
21
  };
20
22
  //#endregion
21
- export { isAdmin, userToShell };
23
+ export { hueOf, initialsOf, isAdmin, userToShell };
@@ -15,11 +15,21 @@ export declare const useClient: () => {
15
15
  etag?: string;
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
+ generate: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], action: string) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
18
19
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
19
20
  history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
20
21
  pageSize?: number;
21
22
  pageToken?: string;
22
23
  }) => Promise<import("@protobase/client").HistoryPage>;
24
+ comments: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
25
+ pageSize?: number;
26
+ pageToken?: string;
27
+ }) => Promise<import("@protobase/client").CommentPage>;
28
+ comment: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { body, mentions }: {
29
+ body: string;
30
+ mentions?: string[];
31
+ }) => Promise<import("@protobase/client").RecordComment>;
32
+ mentionable: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], query: string) => Promise<import("@protobase/client").MentionableUser[]>;
23
33
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
24
34
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
25
35
  filter?: import("@protobase/client").FilterInput;
@@ -63,11 +73,21 @@ export declare const useResourceClient: (resource: string) => {
63
73
  etag?: string;
64
74
  }) => Promise<void>;
65
75
  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>>>;
76
+ generate: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], action: string) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
66
77
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
67
78
  history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
68
79
  pageSize?: number;
69
80
  pageToken?: string;
70
81
  }) => Promise<import("@protobase/client").HistoryPage>;
82
+ comments: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
83
+ pageSize?: number;
84
+ pageToken?: string;
85
+ }) => Promise<import("@protobase/client").CommentPage>;
86
+ comment: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { body, mentions }: {
87
+ body: string;
88
+ mentions?: string[];
89
+ }) => Promise<import("@protobase/client").RecordComment>;
90
+ mentionable: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], query: string) => Promise<import("@protobase/client").MentionableUser[]>;
71
91
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
72
92
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
73
93
  filter?: import("@protobase/client").FilterInput;
@@ -66,6 +66,21 @@ const keys = {
66
66
  "history",
67
67
  pageSize
68
68
  ],
69
+ /** A record's comments; starts with `record`, so refreshing the record refreshes them too. */
70
+ comments: (resource, key) => [
71
+ "record",
72
+ resource,
73
+ key,
74
+ "comments"
75
+ ],
76
+ /** The people a comment on a record may mention whose name contains `query`. */
77
+ mentionable: (resource, key, query) => [
78
+ "record",
79
+ resource,
80
+ key,
81
+ "mentionable",
82
+ query
83
+ ],
69
84
  facets: (resource, field, filter) => [
70
85
  "facets",
71
86
  resource,
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { RouteLink } from "../primitives/route-link.js";
2
3
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
4
  import { MoreHorizontal } from "lucide-react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -70,7 +71,13 @@ const RowMenu = ({ row, actions }) => {
70
71
  className: "fixed z-50 min-w-36 rounded-md border bg-background py-1 shadow-pop",
71
72
  children: actions.map((action) => /* @__PURE__ */ jsx("li", {
72
73
  role: "none",
73
- children: /* @__PURE__ */ jsx("button", {
74
+ children: action.link ? /* @__PURE__ */ jsx(RouteLink, {
75
+ role: "menuitem",
76
+ to: action.link(row),
77
+ onClick: () => setOpen(false),
78
+ className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
79
+ children: action.label
80
+ }) : /* @__PURE__ */ jsx("button", {
74
81
  type: "button",
75
82
  role: "menuitem",
76
83
  className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
@@ -16,9 +16,35 @@ const time = new Intl.DateTimeFormat("en-GB", {
16
16
  hour12: false,
17
17
  timeZone: "UTC"
18
18
  });
19
+ const month = new Intl.DateTimeFormat("en-GB", {
20
+ month: "long",
21
+ year: "numeric",
22
+ timeZone: "UTC"
23
+ });
24
+ const weekday = new Intl.DateTimeFormat("en-GB", {
25
+ weekday: "short",
26
+ timeZone: "UTC"
27
+ });
28
+ const weekdayDate = new Intl.DateTimeFormat("en-GB", {
29
+ weekday: "long",
30
+ day: "numeric",
31
+ month: "short",
32
+ year: "numeric",
33
+ timeZone: "UTC"
34
+ });
19
35
  const formatDate = (ms) => day.format(ms);
20
36
  const formatDayShort = (ms) => dayShort.format(ms);
21
37
  const formatDateTime = (ms) => `${day.format(ms)}, ${time.format(ms)}`;
38
+ /** "October 2026". */
39
+ const formatMonth = (ms) => month.format(ms);
40
+ /** "Mon". */
41
+ const formatWeekday = (ms) => weekday.format(ms);
42
+ /** "Monday, 12 Oct 2026". */
43
+ const formatWeekdayDate = (ms) => weekdayDate.format(ms);
44
+ /** "12–18 Oct 2026", "28 Dec 2026 – 3 Jan 2027": the days from `from` to `to`, both included. */
45
+ const formatDayRange = (from, to) => day.formatRange(from, to);
46
+ /** "09:30". */
47
+ const formatTime = (ms) => time.format(ms);
22
48
  const formatAgo = (seconds) => {
23
49
  if (seconds < 60) return `${seconds} s ago`;
24
50
  if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
@@ -40,4 +66,4 @@ const formatRelativeTime = (seconds) => {
40
66
  return relative.format(-Math.floor(seconds / size), unit);
41
67
  };
42
68
  //#endregion
43
- export { formatAgo, formatDate, formatDateTime, formatDayShort, formatRelativeTime };
69
+ export { formatAgo, formatDate, formatDateTime, formatDayRange, formatDayShort, formatMonth, formatRelativeTime, formatTime, formatWeekday, formatWeekdayDate };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.21.7";
2
+ var version = "0.21.8";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -11,10 +11,15 @@ const sizes = {
11
11
  option: "px-3 text-body"
12
12
  }
13
13
  };
14
+ /** A segmented switch's classes, for a row of links that looks like one: the group, and each option, `active` the chosen. */
15
+ const segmentedClass = {
16
+ group: (size = "sm") => cn("inline-flex", sizes[size].group, "rounded-md bg-muted p-0.5"),
17
+ option: (active, size = "sm") => cn("inline-flex items-center gap-1.5 rounded", sizes[size].option, "font-medium transition-colors", active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")
18
+ };
14
19
  const Segmented = ({ value, options, onChange, label, size = "sm", iconOnly = false }) => /* @__PURE__ */ jsx("span", {
15
20
  role: "group",
16
21
  "aria-label": label,
17
- className: cn("inline-flex", sizes[size].group, "rounded-md bg-muted p-0.5"),
22
+ className: segmentedClass.group(size),
18
23
  children: options.map((option) => /* @__PURE__ */ jsxs("button", {
19
24
  type: "button",
20
25
  "aria-pressed": option.value === value,
@@ -23,9 +28,9 @@ const Segmented = ({ value, options, onChange, label, size = "sm", iconOnly = fa
23
28
  title: option.label
24
29
  },
25
30
  onClick: () => onChange(option.value),
26
- className: cn("inline-flex items-center gap-1.5 rounded", sizes[size].option, "font-medium transition-colors", option.value === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
31
+ className: segmentedClass.option(option.value === value, size),
27
32
  children: [option.icon && /* @__PURE__ */ jsx(option.icon, { className: "size-3.5" }), !iconOnly && option.label]
28
33
  }, option.value))
29
34
  });
30
35
  //#endregion
31
- export { Segmented };
36
+ export { Segmented, segmentedClass };
@@ -1,9 +1,10 @@
1
- import { TextareaHTMLAttributes } from "react";
1
+ import { Ref, TextareaHTMLAttributes } from "react";
2
2
  //#region src/primitives/textarea.d.ts
3
3
  export type TextareaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value'> & {
4
4
  value: string;
5
5
  invalid?: boolean;
6
+ ref?: Ref<HTMLTextAreaElement>;
6
7
  };
7
- /** A text box of several lines that grows with its text instead of scrolling inside. */
8
- export declare const Textarea: ({ invalid, className, rows, value, ...rest }: TextareaProps) => import("react").JSX.Element;
8
+ /** A text box of several lines that grows with its text instead of scrolling inside; `ref` reaches the box too. */
9
+ export declare const Textarea: ({ invalid, className, rows, value, ref: given, ...rest }: TextareaProps) => import("react").JSX.Element;
9
10
  //#endregion
@@ -1,15 +1,20 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useFieldControl } from "./field-control.js";
3
- import { useEffect, useLayoutEffect, useRef } from "react";
3
+ import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  //#region src/primitives/textarea.tsx
6
6
  const fit = (box) => {
7
7
  box.style.height = "auto";
8
8
  box.style.height = `${box.scrollHeight + box.offsetHeight - box.clientHeight}px`;
9
9
  };
10
- /** A text box of several lines that grows with its text instead of scrolling inside. */
11
- const Textarea = ({ invalid, className, rows = 3, value, ...rest }) => {
10
+ /** A text box of several lines that grows with its text instead of scrolling inside; `ref` reaches the box too. */
11
+ const Textarea = ({ invalid, className, rows = 3, value, ref: given, ...rest }) => {
12
12
  const ref = useRef(null);
13
+ const setRef = useCallback((box) => {
14
+ ref.current = box;
15
+ if (typeof given === "function") given(box);
16
+ else if (given) given.current = box;
17
+ }, [given]);
13
18
  const control = useFieldControl();
14
19
  useLayoutEffect(() => {
15
20
  if (ref.current) fit(ref.current);
@@ -27,7 +32,7 @@ const Textarea = ({ invalid, className, rows = 3, value, ...rest }) => {
27
32
  return () => observer.disconnect();
28
33
  }, []);
29
34
  return /* @__PURE__ */ jsx("textarea", {
30
- ref,
35
+ ref: setRef,
31
36
  rows,
32
37
  value,
33
38
  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),