canopycms 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +66 -42
  2. package/dist/ai/generate.d.ts +3 -0
  3. package/dist/ai/generate.js +12 -29
  4. package/dist/ai/handler.js +1 -0
  5. package/dist/ai/types.d.ts +3 -9
  6. package/dist/api/reference-options.d.ts +2 -5
  7. package/dist/api/reference-options.js +21 -2
  8. package/dist/api/schema.d.ts +4 -4
  9. package/dist/build/generate-ai-content.js +1 -0
  10. package/dist/cli/cli.js +131 -38
  11. package/dist/cli/generate-ai-content.js +75 -32
  12. package/dist/cli/init.js +29 -4
  13. package/dist/config/index.d.ts +1 -1
  14. package/dist/config/schemas/config.d.ts +74 -0
  15. package/dist/config/schemas/config.js +21 -0
  16. package/dist/config/schemas/field.d.ts +30 -0
  17. package/dist/config/schemas/field.js +22 -0
  18. package/dist/config/types.d.ts +39 -0
  19. package/dist/config/validation.d.ts +6 -0
  20. package/dist/config/validation.js +33 -0
  21. package/dist/editor/FormRenderer.js +15 -13
  22. package/dist/editor/copy.d.ts +5 -0
  23. package/dist/editor/copy.js +5 -0
  24. package/dist/editor/fields/BlockField.d.ts +2 -0
  25. package/dist/editor/fields/BlockField.js +15 -8
  26. package/dist/editor/fields/ItemList.d.ts +21 -3
  27. package/dist/editor/fields/ItemList.js +16 -8
  28. package/dist/editor/fields/NumberListField.d.ts +2 -0
  29. package/dist/editor/fields/NumberListField.js +6 -2
  30. package/dist/editor/fields/ObjectListField.js +9 -3
  31. package/dist/editor/fields/ReferenceField.d.ts +6 -7
  32. package/dist/editor/fields/ReferenceField.js +28 -122
  33. package/dist/editor/fields/ReferenceListField.d.ts +41 -0
  34. package/dist/editor/fields/ReferenceListField.js +139 -0
  35. package/dist/editor/fields/SelectField.d.ts +2 -0
  36. package/dist/editor/fields/SelectField.js +6 -3
  37. package/dist/editor/fields/StringListField.d.ts +10 -1
  38. package/dist/editor/fields/StringListField.js +18 -8
  39. package/dist/editor/fields/item-expansion.d.ts +7 -3
  40. package/dist/editor/fields/item-expansion.js +6 -4
  41. package/dist/editor/fields/list-item-keys.d.ts +6 -5
  42. package/dist/editor/fields/list-item-keys.js +45 -13
  43. package/dist/editor/fields/reference-noindex.d.ts +37 -0
  44. package/dist/editor/fields/reference-noindex.js +48 -0
  45. package/dist/editor/fields/use-reference-options.d.ts +36 -0
  46. package/dist/editor/fields/use-reference-options.js +121 -0
  47. package/dist/editor/form-outline.d.ts +2 -2
  48. package/dist/editor/form-outline.js +4 -2
  49. package/dist/editor/theme.js +5 -0
  50. package/dist/entry-schema-registry.d.ts +1 -1
  51. package/dist/entry-schema-registry.js +3 -2
  52. package/dist/entry-schema.d.ts +5 -3
  53. package/dist/entry-schema.js +2 -1
  54. package/dist/reference-resolver.d.ts +18 -1
  55. package/dist/reference-resolver.js +175 -8
  56. package/dist/schema/schema-store.d.ts +2 -2
  57. package/dist/static/index.d.ts +8 -4
  58. package/dist/static/index.js +38 -13
  59. package/dist/static/seo.d.ts +7 -5
  60. package/dist/static/seo.js +13 -6
  61. package/dist/validation/entry-validator.d.ts +5 -0
  62. package/dist/validation/entry-validator.js +12 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/package.json +1 -1
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import React, { useId, useRef, useState } from 'react';
4
- import { ActionIcon, Box, Button, Group, Menu, Paper, ScrollArea, Stack, Text, Tooltip, } from '@mantine/core';
4
+ import { ActionIcon, Box, Button, Group, Menu, Paper, ScrollArea, Stack, Text, Tooltip, VisuallyHidden, } from '@mantine/core';
5
5
  import { useMediaQuery } from '@mantine/hooks';
6
6
  import { IconPlus } from '@tabler/icons-react';
7
7
  import { ITEM_KEY, mintItemKey, withFreshItemKeys } from '../../item-keys.js';
8
8
  import { isPlainRecord } from '../../validation/field-traversal.js';
9
9
  import { formatCanopyPath } from '../canopy-path.js';
10
+ import { listFullReason } from '../copy.js';
10
11
  import { useFieldCommentControl } from '../comments/field-comment-context.js';
11
12
  import { ExpandAllButton } from './ExpandAllButton.js';
12
13
  import { groupDescriptionProps } from './FieldDescription.js';
@@ -22,12 +23,13 @@ const ADD_SECTION = 'Add section';
22
23
  * The block templates as a menu, each with its description, so an editor picks a section by
23
24
  * what it is for. `target` is the control that opens it.
24
25
  */
25
- const TemplateMenu = ({ templates, onPick, onOpenChange, target }) => (_jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "md", width: 320, onChange: onOpenChange, children: [target, _jsx(Menu.Dropdown, { "data-testid": "block-template-menu", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "auto", children: templates.map((template) => (_jsxs(Menu.Item, { onClick: () => onPick(template.name), "data-testid": `block-template-${template.name}`, children: [_jsx(Text, { size: "sm", fw: 500, children: schemaLabel(template) }), template.description && (_jsx(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'normal' }, children: template.description }))] }, template.name))) }) })] }));
26
+ const TemplateMenu = ({ templates, onPick, onOpenChange, disabled, target }) => (_jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "md", width: 320, onChange: onOpenChange, disabled: disabled, children: [target, _jsx(Menu.Dropdown, { "data-testid": "block-template-menu", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "auto", children: templates.map((template) => (_jsxs(Menu.Item, { onClick: () => onPick(template.name), "data-testid": `block-template-${template.name}`, children: [_jsx(Text, { size: "sm", fw: 500, children: schemaLabel(template) }), template.description && (_jsx(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'normal' }, children: template.description }))] }, template.name))) }) })] }));
26
27
  /**
27
28
  * The gap before a block. Its add control shows while the gap is hovered, the control is focused
28
29
  * or its menu is open, and stays in the tab order, so a keyboard reaches it before each block.
29
30
  */
30
- const InsertBetween = ({ templates, onPick }) => {
31
+ const InsertBetween = ({ templates, onPick, blockedReason }) => {
32
+ const reasonId = useId();
31
33
  const [hovered, setHovered] = useState(false);
32
34
  const [focused, setFocused] = useState(false);
33
35
  const [opened, setOpened] = useState(false);
@@ -35,9 +37,9 @@ const InsertBetween = ({ templates, onPick }) => {
35
37
  const noHover = useMediaQuery('(hover: none)');
36
38
  const shown = hovered || focused || opened;
37
39
  const opacity = shown ? 1 : noHover ? 0.6 : 0;
38
- return (_jsxs(Box, { h: 20, pos: "relative", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), "data-testid": "block-insert-between", children: [_jsx(Box, { "aria-hidden": true, pos: "absolute", left: 0, right: 0, top: "50%", h: 2, bg: "var(--mantine-primary-color-filled)", style: { opacity: shown ? 1 : 0, transition: 'opacity 120ms ease' } }), _jsx(Group, { justify: "center", h: "100%", style: { position: 'relative' }, children: _jsx(TemplateMenu, { templates: templates, onPick: onPick, onOpenChange: setOpened, target: _jsx(Tooltip, { label: "Add section here", openDelay: 300, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "sm", radius: "xl", variant: "default", "aria-label": "Add section here", onFocus: () => setFocused(true), onBlur: () => setFocused(false), style: { opacity, transition: 'opacity 120ms ease' }, children: _jsx(IconPlus, { size: 14, "aria-hidden": true }) }) }) }) }) })] }));
40
+ return (_jsxs(Box, { h: 20, pos: "relative", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), "data-testid": "block-insert-between", children: [_jsx(Box, { "aria-hidden": true, pos: "absolute", left: 0, right: 0, top: "50%", h: 2, bg: "var(--mantine-primary-color-filled)", style: { opacity: shown ? 1 : 0, transition: 'opacity 120ms ease' } }), _jsx(Group, { justify: "center", h: "100%", style: { position: 'relative' }, children: _jsx(TemplateMenu, { templates: templates, onPick: onPick, onOpenChange: setOpened, disabled: !!blockedReason, target: _jsx(Tooltip, { label: blockedReason ?? 'Add section here', openDelay: 300, multiline: !!blockedReason, maw: 280, children: _jsx(Menu.Target, { children: _jsxs(ActionIcon, { size: "sm", radius: "xl", variant: "default", "aria-label": "Add section here", "aria-disabled": blockedReason ? true : undefined, "aria-describedby": blockedReason ? reasonId : undefined, "data-disabled": blockedReason ? true : undefined, onFocus: () => setFocused(true), onBlur: () => setFocused(false), style: { opacity, transition: 'opacity 120ms ease' }, children: [_jsx(IconPlus, { size: 14, "aria-hidden": true }), blockedReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: blockedReason }))] }) }) }) }) })] }));
39
41
  };
40
- export const BlockField = ({ label, required, description, templates, value, onChange, renderField, path, dataCanopyField, readOnly = false, resetKey = '', hasErrors, onRemoved, }) => {
42
+ export const BlockField = ({ label, required, description, templates, value, onChange, renderField, path, dataCanopyField, readOnly = false, resetKey = '', hasErrors, onRemoved, maxItems, }) => {
41
43
  const descriptionBaseId = useId();
42
44
  const commentControl = useFieldCommentControl(dataCanopyField ?? formatCanopyPath(path));
43
45
  const keysFor = useListItemKeys();
@@ -48,7 +50,12 @@ export const BlockField = ({ label, required, description, templates, value, onC
48
50
  if (!readOnly)
49
51
  onChange(next);
50
52
  };
53
+ const fullReason = maxItems !== undefined && value.length >= maxItems
54
+ ? listFullReason(maxItems, 'section', 'sections')
55
+ : undefined;
51
56
  const insertAt = (index, block) => {
57
+ if (fullReason)
58
+ return;
52
59
  focusAfterRenderRef.current = index;
53
60
  emit([...value.slice(0, index), block, ...value.slice(index)]);
54
61
  };
@@ -81,7 +88,7 @@ export const BlockField = ({ label, required, description, templates, value, onC
81
88
  ...value.slice(0, idx + 1),
82
89
  withFreshItemKeys({ type: 'block', name: 'blocks', templates }, structuredClone(value[idx])),
83
90
  ...value.slice(idx + 1),
84
- ]), onRemove: (idx) => {
91
+ ]), duplicateBlockedReason: fullReason, onRemove: (idx) => {
85
92
  if (readOnly)
86
93
  return;
87
94
  const block = value[idx];
@@ -89,7 +96,7 @@ export const BlockField = ({ label, required, description, templates, value, onC
89
96
  onRemoved?.(idx, block);
90
97
  }, renderBetween: readOnly
91
98
  ? undefined
92
- : (idx) => _jsx(InsertBetween, { templates: templates, onPick: addAt(idx) }), renderItem: (block, idx) => {
99
+ : (idx) => (_jsx(InsertBetween, { templates: templates, onPick: addAt(idx), blockedReason: fullReason })), renderItem: (block, idx) => {
93
100
  const template = findTemplate(templates, block.template);
94
101
  const currentPath = [...path, idx];
95
102
  if (!template) {
@@ -99,5 +106,5 @@ export const BlockField = ({ label, required, description, templates, value, onC
99
106
  ...(isPlainRecord(block.value) ? block.value : {}),
100
107
  [f.name]: next,
101
108
  }), [...currentPath, f.name]) }, f.name)));
102
- } }), value.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'No sections.' : 'No sections yet.' })), !readOnly && (_jsx(Group, { children: _jsx(TemplateMenu, { templates: templates, onPick: addAt(value.length), target: _jsx(Menu.Target, { children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), "data-testid": "block-add-section", children: ADD_SECTION }) }) }) }))] }) }));
109
+ } }), value.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'No sections.' : 'No sections yet.' })), !readOnly && (_jsx(Group, { children: _jsx(TemplateMenu, { templates: templates, onPick: addAt(value.length), disabled: !!fullReason, target: _jsx(Tooltip, { label: fullReason, disabled: !fullReason, multiline: true, maw: 280, children: _jsx(Menu.Target, { children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), "data-disabled": fullReason ? true : undefined, "aria-disabled": fullReason ? true : undefined, "data-testid": "block-add-section", children: ADD_SECTION }) }) }) }) }))] }) }));
103
110
  };
@@ -1,9 +1,17 @@
1
1
  import React from 'react';
2
+ import { type MantineColor } from '@mantine/core';
2
3
  import type { ItemExpansion } from './item-expansion.js';
3
4
  /** What a collapsed row shows: its title, and a second value from its content. */
4
5
  interface ItemSummary {
5
6
  title: string;
6
7
  secondary?: string;
8
+ /** A status shown after the title, in its colour's light variant; part of the row's name. */
9
+ badge?: {
10
+ label: string;
11
+ color: MantineColor;
12
+ };
13
+ /** The title is still loading: the row shows a placeholder in its place. */
14
+ pending?: boolean;
7
15
  }
8
16
  export interface ItemListProps<T> {
9
17
  items: readonly T[];
@@ -20,7 +28,10 @@ export interface ItemListProps<T> {
20
28
  /** True when a field inside row `index` has a validation error. */
21
29
  hasErrors?: (index: number) => boolean;
22
30
  onMove: (from: number, to: number) => void;
23
- onDuplicate: (index: number) => void;
31
+ /** Without it, the row menu offers no Duplicate. */
32
+ onDuplicate?: (index: number) => void;
33
+ /** Why Duplicate can't act now, such as a list at its `maxItems`; shown in its place. */
34
+ duplicateBlockedReason?: string;
24
35
  /** Takes row `index` out; focus has already moved to the enclosing `[data-removal-focus]` list. */
25
36
  onRemove: (index: number) => void;
26
37
  /** Rendered before row `index`, such as a block list's insert-here control. */
@@ -30,13 +41,20 @@ export interface ItemListProps<T> {
30
41
  * Cleared once used.
31
42
  */
32
43
  focusAfterRenderRef?: React.MutableRefObject<number | null>;
44
+ /**
45
+ * Said through the list's live region, by a caller whose own control keeps focus when it adds a
46
+ * row. Pass a new object for each announcement.
47
+ */
48
+ announcement?: {
49
+ message: string;
50
+ };
33
51
  'data-testid'?: string;
34
52
  }
35
53
  /**
36
54
  * The rows of an object list or block list: each collapses to a one-line summary and carries a
37
55
  * drag handle (pointer and keyboard, via dnd-kit) and a "…" menu with Move up, Move down,
38
- * Duplicate and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
56
+ * Duplicate (when the list offers it) and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
39
57
  * and opens when something asks to reveal a field inside it (`REVEAL_FIELD_EVENT`).
40
58
  */
41
- export declare function ItemList<T>({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, 'data-testid': testId, }: ItemListProps<T>): import("react/jsx-runtime").JSX.Element;
59
+ export declare function ItemList<T>({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, announcement: callerAnnouncement, duplicateBlockedReason, 'data-testid': testId, }: ItemListProps<T>): import("react/jsx-runtime").JSX.Element;
42
60
  export {};
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import React, { useEffect, useId, useRef, useState } from 'react';
4
- import { ActionIcon, Box, Group, Menu, Paper, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
4
+ import { ActionIcon, Badge, Box, Group, Menu, Paper, Skeleton, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
5
  import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
6
6
  import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
7
7
  import { CSS } from '@dnd-kit/utilities';
@@ -17,10 +17,10 @@ const ROW_GAP = 8;
17
17
  /**
18
18
  * The rows of an object list or block list: each collapses to a one-line summary and carries a
19
19
  * drag handle (pointer and keyboard, via dnd-kit) and a "…" menu with Move up, Move down,
20
- * Duplicate and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
20
+ * Duplicate (when the list offers it) and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
21
21
  * and opens when something asks to reveal a field inside it (`REVEAL_FIELD_EVENT`).
22
22
  */
23
- export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, 'data-testid': testId, }) {
23
+ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, announcement: callerAnnouncement, duplicateBlockedReason, 'data-testid': testId, }) {
24
24
  const listRef = useRef(null);
25
25
  const [announcement, setAnnouncement] = useState('');
26
26
  // Constant: DndContext's hooks depend on the sensor count; read-only disables each row instead.
@@ -36,6 +36,12 @@ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize
36
36
  if (row)
37
37
  focusFieldTarget(row);
38
38
  });
39
+ const [lastCallerAnnouncement, setLastCallerAnnouncement] = useState(callerAnnouncement);
40
+ if (callerAnnouncement !== lastCallerAnnouncement) {
41
+ setLastCallerAnnouncement(callerAnnouncement);
42
+ if (callerAnnouncement)
43
+ setAnnouncement(callerAnnouncement.message);
44
+ }
39
45
  const titleAt = (index) => summarize(items[index], index).title;
40
46
  const indexOf = (id) => itemKeys.indexOf(String(id));
41
47
  const position = (index) => `position ${index + 1} of ${items.length}`;
@@ -63,7 +69,7 @@ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize
63
69
  setAnnouncement(`${titleAt(from)} moved to ${position(to)}.`);
64
70
  };
65
71
  const duplicate = (index) => {
66
- onDuplicate(index);
72
+ onDuplicate?.(index);
67
73
  setAnnouncement(`${titleAt(index)} duplicated, position ${index + 2} of ${items.length + 1}.`);
68
74
  };
69
75
  const remove = (index) => {
@@ -74,10 +80,10 @@ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize
74
80
  return (_jsxs("div", { ref: listRef, "data-item-list": true, "data-testid": testId, children: [_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, accessibility: { announcements }, children: _jsx(SortableContext, { items: [...itemKeys], strategy: verticalListSortingStrategy, children: items.map((item, idx) => {
75
81
  const key = itemKeys[idx];
76
82
  const summary = summarize(item, idx);
77
- return (_jsxs(React.Fragment, { children: [renderBetween ? renderBetween(idx) : idx > 0 && _jsx(Box, { h: ROW_GAP, "aria-hidden": true }), _jsx(ItemRow, { rowKey: key, canopyPath: formatCanopyPath([...path, idx]), summary: summary, description: describe?.(item), hasErrors: hasErrors?.(idx) ?? false, expanded: expansion.isExpanded(key), onExpandedChange: (open) => expansion.setExpanded(key, open), readOnly: readOnly, isFirst: idx === 0, isLast: idx === items.length - 1, onMoveUp: () => move(idx, idx - 1), onMoveDown: () => move(idx, idx + 1), onDuplicate: () => duplicate(idx), onRemove: () => remove(idx), children: renderItem(item, idx) })] }, key));
83
+ return (_jsxs(React.Fragment, { children: [renderBetween ? renderBetween(idx) : idx > 0 && _jsx(Box, { h: ROW_GAP, "aria-hidden": true }), _jsx(ItemRow, { rowKey: key, canopyPath: formatCanopyPath([...path, idx]), summary: summary, description: describe?.(item), hasErrors: hasErrors?.(idx) ?? false, expanded: expansion.isExpanded(key), onExpandedChange: (open) => expansion.setExpanded(key, open), readOnly: readOnly, isFirst: idx === 0, isLast: idx === items.length - 1, onMoveUp: () => move(idx, idx - 1), onMoveDown: () => move(idx, idx + 1), onDuplicate: onDuplicate ? () => duplicate(idx) : undefined, duplicateBlockedReason: duplicateBlockedReason, onRemove: () => remove(idx), children: renderItem(item, idx) })] }, key));
78
84
  }) }) }), _jsx(VisuallyHidden, { "aria-live": "polite", children: announcement })] }));
79
85
  }
80
- const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded, onExpandedChange, readOnly, isFirst, isLast, onMoveUp, onMoveDown, onDuplicate, onRemove, children, }) => {
86
+ const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded, onExpandedChange, readOnly, isFirst, isLast, onMoveUp, onMoveDown, onDuplicate, duplicateBlockedReason, onRemove, children, }) => {
81
87
  const baseId = useId();
82
88
  const bodyId = `${baseId}-body`;
83
89
  const rowRef = useRef(null);
@@ -111,7 +117,7 @@ const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded
111
117
  requestAnimationFrame(() => menuTargetRef.current?.focus());
112
118
  };
113
119
  // Rows can share a title (two sections of one template), so names carry the second value too.
114
- const name = summary.secondary ? `${summary.title}, ${summary.secondary}` : summary.title;
120
+ const name = [summary.title, summary.secondary, summary.badge?.label].filter(Boolean).join(', ');
115
121
  const actionsLabel = `Actions for ${name}`;
116
122
  return (_jsxs(Paper, { ref: (node) => {
117
123
  rowRef.current = node;
@@ -126,5 +132,7 @@ const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded
126
132
  flexShrink: 0,
127
133
  transform: expanded ? 'rotate(90deg)' : undefined,
128
134
  transition: 'transform 120ms ease',
129
- } }), _jsx(Text, { size: "sm", fw: 500, truncate: "end", style: summary.secondary ? { flexShrink: 0, maxWidth: '60%' } : { minWidth: 0 }, children: summary.title }), summary.secondary && (_jsx(Text, { size: "sm", c: "dimmed", truncate: "end", style: { minWidth: 0 }, children: summary.secondary })), hasErrors && !expanded && (_jsxs(Group, { gap: 2, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsx(IconAlertCircle, { size: 14, color: "var(--canopy-danger-text)", "aria-hidden": true }), _jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: "Has errors" })] }))] }) }), !readOnly && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 220, children: [_jsx(Tooltip, { label: actionsLabel, openDelay: 500, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { ref: menuTargetRef, variant: "subtle", color: "gray", "aria-label": actionsLabel, "data-testid": "item-row-menu", children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [_jsx(ReasonedMenuItem, { blockedReason: isFirst ? 'Already first' : undefined, onClick: thenFocusMenu(onMoveUp), "data-testid": "item-row-move-up", children: "Move up" }), _jsx(ReasonedMenuItem, { blockedReason: isLast ? 'Already last' : undefined, onClick: thenFocusMenu(onMoveDown), "data-testid": "item-row-move-down", children: "Move down" }), _jsx(Menu.Item, { onClick: thenFocusMenu(onDuplicate), "data-testid": "item-row-duplicate", children: "Duplicate" }), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { onClick: onRemove, "data-testid": "item-row-remove", children: EDITOR_ACTIONS.remove })] })] }))] }), _jsx(Box, { id: bodyId, hidden: !expanded, children: _jsxs(Stack, { gap: "sm", px: "sm", pb: "sm", pt: 4, children: [description && _jsx(FieldDescription, { baseId: baseId, description: description }), children] }) })] }));
135
+ } }), summary.pending ? (
136
+ // About the height of the title's line, which replaces it.
137
+ _jsx(Skeleton, { height: 14, width: "40%", my: 3, "data-testid": "item-row-title-pending" })) : (_jsx(Text, { size: "sm", fw: 500, truncate: "end", style: summary.secondary ? { flexShrink: 0, maxWidth: '60%' } : { minWidth: 0 }, children: summary.title })), summary.secondary && (_jsx(Text, { size: "sm", c: "dimmed", truncate: "end", style: { minWidth: 0 }, children: summary.secondary })), summary.badge && !summary.pending && (_jsx(Badge, { variant: "light", color: summary.badge.color, size: "sm", tt: "none", style: { flexShrink: 0 }, children: summary.badge.label })), hasErrors && !expanded && (_jsxs(Group, { gap: 2, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsx(IconAlertCircle, { size: 14, color: "var(--canopy-danger-text)", "aria-hidden": true }), _jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: "Has errors" })] }))] }) }), !readOnly && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 220, children: [_jsx(Tooltip, { label: actionsLabel, openDelay: 500, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { ref: menuTargetRef, variant: "subtle", color: "gray", "aria-label": actionsLabel, "data-testid": "item-row-menu", children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [_jsx(ReasonedMenuItem, { blockedReason: isFirst ? 'Already first' : undefined, onClick: thenFocusMenu(onMoveUp), "data-testid": "item-row-move-up", children: "Move up" }), _jsx(ReasonedMenuItem, { blockedReason: isLast ? 'Already last' : undefined, onClick: thenFocusMenu(onMoveDown), "data-testid": "item-row-move-down", children: "Move down" }), onDuplicate && (_jsx(ReasonedMenuItem, { blockedReason: duplicateBlockedReason, onClick: thenFocusMenu(onDuplicate), "data-testid": "item-row-duplicate", children: "Duplicate" })), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { onClick: onRemove, "data-testid": "item-row-remove", children: EDITOR_ACTIONS.remove })] })] }))] }), _jsx(Box, { id: bodyId, hidden: !expanded, children: _jsxs(Stack, { gap: "sm", px: "sm", pb: "sm", pt: 4, children: [description && _jsx(FieldDescription, { baseId: baseId, description: description }), children] }) })] }));
130
138
  };
@@ -8,6 +8,8 @@ export interface NumberListFieldProps {
8
8
  dataCanopyField?: string;
9
9
  /** Shows the value without accepting edits. */
10
10
  readOnly?: boolean;
11
+ /** The most items the list holds; at or over it, nothing adds and the hint says why. */
12
+ maxItems?: number;
11
13
  }
12
14
  /**
13
15
  * Editor for `type: 'number', list: true` fields.
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId, useState } from 'react';
3
3
  import { TagsInput } from '@mantine/core';
4
+ import { listFullReason } from '../copy.js';
4
5
  import { LabelledInput } from './FieldLabel.js';
6
+ import { tagCap, withHint } from './StringListField.js';
5
7
  /**
6
8
  * A tag is a number only if it is non-blank AND parses finitely.
7
9
  *
@@ -28,11 +30,13 @@ const parseTag = (tag) => {
28
30
  * on Enter indistinguishably from being accepted, with nothing to tell a
29
31
  * typo from a broken field — so rejected text is named in the error slot.
30
32
  */
31
- export const NumberListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, }) => {
33
+ export const NumberListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, maxItems, }) => {
32
34
  const generatedId = useId();
33
35
  const inputId = id ?? generatedId;
34
36
  const [rejected, setRejected] = useState([]);
35
- return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(TagsInput, { id: inputId, description: description, value: value.map(String), size: "sm", error: rejected.length > 0
37
+ return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(TagsInput, { id: inputId, description: !readOnly && maxItems !== undefined && value.length >= maxItems
38
+ ? withHint(description, listFullReason(maxItems))
39
+ : description, maxTags: tagCap(value.length, maxItems), value: value.map(String), size: "sm", error: rejected.length > 0
36
40
  ? `Not ${rejected.length === 1 ? 'a number' : 'numbers'}: ${rejected.join(', ')}`
37
41
  : undefined, readOnly: readOnly, onChange: (next) => {
38
42
  if (readOnly)
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef } from 'react';
3
- import { Button, Group, Paper, Stack, Text } from '@mantine/core';
3
+ import { Button, Group, Paper, Stack, Text, Tooltip } from '@mantine/core';
4
4
  import { IconPlus } from '@tabler/icons-react';
5
5
  import { isPlainRecord } from '../../validation/field-traversal.js';
6
6
  import { ITEM_KEY, mintItemKey, withFreshItemKeys } from '../../item-keys.js';
7
7
  import { formatCanopyPath } from '../canopy-path.js';
8
+ import { listFullReason } from '../copy.js';
8
9
  import { useFieldCommentControl } from '../comments/field-comment-context.js';
9
10
  import { groupDescriptionProps } from './FieldDescription.js';
10
11
  import { FieldLabel } from './FieldLabel.js';
@@ -20,7 +21,12 @@ export const ObjectListField = ({ id, label, field, items, itemKeys, onChange, r
20
21
  const commentControl = useFieldCommentControl(canopyPath);
21
22
  const expansion = useItemExpansion(itemKeys, resetKey);
22
23
  const focusAfterRenderRef = useRef(null);
24
+ const fullReason = field.maxItems !== undefined && items.length >= field.maxItems
25
+ ? listFullReason(field.maxItems)
26
+ : undefined;
23
27
  const insertAt = (index, item) => {
28
+ if (fullReason)
29
+ return;
24
30
  focusAfterRenderRef.current = index;
25
31
  onChange([...items.slice(0, index), item, ...items.slice(index)]);
26
32
  };
@@ -38,7 +44,7 @@ export const ObjectListField = ({ id, label, field, items, itemKeys, onChange, r
38
44
  ...items.slice(0, idx + 1),
39
45
  withFreshItemKeys(field, structuredClone(items[idx])),
40
46
  ...items.slice(idx + 1),
41
- ]), onRemove: (idx) => {
47
+ ]), duplicateBlockedReason: fullReason, onRemove: (idx) => {
42
48
  const item = items[idx];
43
49
  onChange(items.filter((_, i) => i !== idx));
44
50
  onRemoved?.(idx, item);
@@ -53,5 +59,5 @@ export const ObjectListField = ({ id, label, field, items, itemKeys, onChange, r
53
59
  onChange(nextItems);
54
60
  }, childPath) }, formatCanopyPath(childPath)));
55
61
  });
56
- } }), items.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'Nothing added.' : 'Nothing added yet.' })), !readOnly && (_jsx(Group, { children: _jsxs(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), onClick: () => insertAt(items.length, { [ITEM_KEY]: mintItemKey() }), "data-testid": "object-list-add", children: ["Add ", field.itemLabel ?? 'item'] }) }))] }) }));
62
+ } }), items.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'Nothing added.' : 'Nothing added yet.' })), !readOnly && (_jsx(Group, { children: _jsx(Tooltip, { label: fullReason, disabled: !fullReason, multiline: true, maw: 280, children: _jsxs(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), onClick: () => insertAt(items.length, { [ITEM_KEY]: mintItemKey() }), "data-disabled": fullReason ? true : undefined, "aria-disabled": fullReason ? true : undefined, "data-testid": "object-list-add", children: ["Add ", field.itemLabel ?? 'item'] }) }) }))] }) }));
57
63
  };
@@ -1,9 +1,8 @@
1
1
  import React from 'react';
2
+ import type { ReferenceOptionSort } from '../../config/index.js';
3
+ import { type ReferenceOption } from './use-reference-options.js';
2
4
  /** @internal Exported for tests. */
3
- export interface ReferenceOption {
4
- value: string;
5
- label: string;
6
- }
5
+ export type { ReferenceOption };
7
6
  export interface ReferenceFieldProps {
8
7
  id?: string;
9
8
  label?: string;
@@ -13,9 +12,9 @@ export interface ReferenceFieldProps {
13
12
  entryTypes?: string[];
14
13
  displayField?: string;
15
14
  branch?: string;
16
- value: string | string[];
17
- onChange: (value: string | string[]) => void;
18
- multiple?: boolean;
15
+ optionSort?: ReferenceOptionSort;
16
+ value: unknown;
17
+ onChange: (value: string) => void;
19
18
  dataCanopyField?: string;
20
19
  /** Shows the value without accepting edits. */
21
20
  readOnly?: boolean;
@@ -1,136 +1,42 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useState } from 'react';
4
- import { Alert, Button, MultiSelect, Select, Stack, Text, Loader } from '@mantine/core';
3
+ import { useId, useMemo } from 'react';
4
+ import { Alert, Button, Group, Select, Stack, Text, Loader } from '@mantine/core';
5
5
  import { IconAlertCircle } from '@tabler/icons-react';
6
- import { createApiClient } from '../../api/client.js';
7
- import { useOptionalApiClient } from '../context/index.js';
8
- import { getErrorMessage } from '../../utils/error.js';
9
6
  import { useFieldCommentControl } from '../comments/field-comment-context.js';
10
7
  import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
11
8
  import { FieldLabel } from './FieldLabel.js';
12
- /** Label for a set value the options omit (a restricted, missing or unlisted target). */
13
- function unlistedReferenceLabel(value, id) {
14
- if (value && typeof value === 'object' && 'unavailable' in value && value.unavailable === true) {
15
- if ('reason' in value && value.reason === 'restricted' && 'title' in value) {
16
- return `Restricted entry: ${String(value.title)}`;
17
- }
18
- return `Missing entry (${id})`;
19
- }
20
- return `Unavailable entry (${id})`;
21
- }
22
- export const ReferenceField = ({ id, label, description, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', value, onChange, multiple, dataCanopyField, readOnly = false, }) => {
23
- // Configured with the deployment's basePath when rendered inside an
24
- // ApiClientProvider (always true in the real Editor tree); `null` outside one
25
- // (e.g. FormRenderer.stories.tsx), so the fetch effect falls back to a
26
- // default-configured client.
27
- const contextApiClient = useOptionalApiClient();
28
- const hasCollections = !!collections && collections.length > 0;
29
- const hasEntryTypes = !!entryTypes && entryTypes.length > 0;
30
- const needsFetch = !staticOptions && (hasCollections || hasEntryTypes);
31
- const [options, setOptions] = useState(staticOptions || []);
32
- const [loading, setLoading] = useState(needsFetch);
33
- const [error, setError] = useState(null);
34
- const [retryCount, setRetryCount] = useState(0);
35
- const extractId = (val) => {
36
- if (typeof val === 'string')
37
- return val;
38
- if (val && typeof val === 'object' && 'id' in val && typeof val.id === 'string')
39
- return val.id;
40
- return '';
41
- };
42
- const normalizedValue = multiple
43
- ? Array.isArray(value)
44
- ? value.map(extractId)
45
- : []
46
- : extractId(value);
9
+ import { HiddenFromSearchBadge, HiddenPicksNote, referenceOptionRenderer, } from './reference-noindex.js';
10
+ import { extractId, unlistedReferenceLabel, useReferenceOptions, } from './use-reference-options.js';
11
+ export const ReferenceField = ({ id, label, description, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', optionSort, value, onChange, dataCanopyField, readOnly = false, }) => {
12
+ const { options, loading, error, retry } = useReferenceOptions({
13
+ staticOptions,
14
+ collections,
15
+ entryTypes,
16
+ displayField,
17
+ branch,
18
+ optionSort,
19
+ });
20
+ const normalizedValue = extractId(value);
47
21
  // Otherwise Select shows a set value as empty, inviting an editor to repoint it.
48
22
  const listed = new Set(options.map((option) => option.value));
49
- const unlisted = new Map();
50
- for (const raw of multiple ? (Array.isArray(value) ? value : []) : [value]) {
51
- const valueId = extractId(raw);
52
- if (valueId && !listed.has(valueId) && !unlisted.has(valueId)) {
53
- unlisted.set(valueId, { value: valueId, label: unlistedReferenceLabel(raw, valueId) });
54
- }
55
- }
56
- const data = unlisted.size > 0 ? [...options, ...unlisted.values()] : options;
23
+ const data = normalizedValue && !listed.has(normalizedValue)
24
+ ? [
25
+ ...options,
26
+ { value: normalizedValue, label: unlistedReferenceLabel(value, normalizedValue) },
27
+ ]
28
+ : options;
57
29
  const generatedId = useId();
58
30
  const inputId = id ?? generatedId;
59
31
  const commentControl = useFieldCommentControl(dataCanopyField);
60
- // Track deps to detect changes and reset loading during render
61
- const [prevFetchKey, setPrevFetchKey] = useState('');
62
- const fetchKey = needsFetch
63
- ? `${collections?.join(',') ?? ''}:${entryTypes?.join(',') ?? ''}:${displayField}:${branch}`
64
- : '';
65
- if (fetchKey !== prevFetchKey) {
66
- setPrevFetchKey(fetchKey);
67
- if (needsFetch) {
68
- setLoading(true);
69
- setError(null);
70
- }
71
- }
72
- // Keyed on the derived `fetchKey`, not the raw `collections`/`entryTypes`
73
- // arrays: the parent rebuilds those on every render (including every
74
- // `refreshEntries()` after a save), so depending on them directly fired
75
- // this fetch far more often than the inputs actually changed.
76
- //
77
- // `active` guards against a stale response: if the key changes again or the
78
- // field unmounts before this request settles, the loser's `.then`/`.catch`/
79
- // `.finally` must not overwrite state a newer request already owns.
80
- useEffect(() => {
81
- if (!needsFetch)
82
- return;
83
- let active = true;
84
- const apiClient = contextApiClient ?? createApiClient();
85
- const params = { branch, displayField };
86
- if (collections && collections.length > 0)
87
- params.collections = collections.join(',');
88
- if (entryTypes && entryTypes.length > 0)
89
- params.entryTypes = entryTypes.join(',');
90
- apiClient.content
91
- .getReferenceOptions(params)
92
- .then((response) => {
93
- if (!active)
94
- return;
95
- if (response.ok && response.data?.options) {
96
- const mappedOptions = response.data.options.map((opt) => ({
97
- value: opt.id,
98
- label: opt.label,
99
- }));
100
- setOptions(mappedOptions);
101
- }
102
- else {
103
- setError(response.error || 'Failed to load reference options');
104
- }
105
- })
106
- .catch((err) => {
107
- if (!active)
108
- return;
109
- console.error('Failed to load reference options:', err);
110
- setError(getErrorMessage(err));
111
- })
112
- .finally(() => {
113
- if (!active)
114
- return;
115
- setLoading(false);
116
- });
117
- return () => {
118
- active = false;
119
- };
120
- // eslint-disable-next-line react-hooks/exhaustive-deps -- collections/entryTypes/displayField/branch are captured via fetchKey; re-listing them here would defeat the point of keying on the derived string
121
- }, [needsFetch, fetchKey, retryCount, contextApiClient]);
122
- const handleRetry = () => {
123
- setLoading(true);
124
- setError(null);
125
- setRetryCount((count) => count + 1);
126
- };
127
32
  const settled = !loading && !error;
33
+ // Stable between renders, as in ReferenceListField.
34
+ const renderOption = useMemo(() => referenceOptionRenderer(options), [options]);
35
+ const pickedOption = options.find((option) => option.value === normalizedValue);
36
+ const hidden = settled && pickedOption?.noindex ? pickedOption : undefined;
128
37
  // One label row for every state, so the comment popover in it survives loading and Retry.
129
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...(settled ? {} : groupDescriptionProps(inputId, description)), children: [_jsx(FieldLabel, { label: label, htmlFor: settled ? inputId : undefined, commentControl: commentControl }), loading ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] })) : error ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { variant: "light", onClick: handleRetry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] })) : multiple ? (_jsx(MultiSelect, { id: inputId, "aria-label": label, description: description, data: data, value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
130
- if (!readOnly)
131
- onChange(next);
132
- }, searchable: true, placeholder: readOnly ? undefined : 'Select reference…', size: "sm" })) : (_jsx(Select, { id: inputId, "aria-label": label, description: description, data: data, value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
133
- if (!readOnly)
134
- onChange(next ?? '');
135
- }, searchable: true, clearable: true, placeholder: readOnly ? undefined : 'Select reference…', size: "sm" }))] }));
38
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...(settled ? {} : groupDescriptionProps(inputId, description)), children: [_jsx(FieldLabel, { label: label, htmlFor: settled ? inputId : undefined, commentControl: commentControl }), loading ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] })) : error ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { variant: "light", onClick: retry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Select, { id: inputId, "aria-label": label, description: description, data: data, renderOption: renderOption, inputContainer: (input) => hidden ? (_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: input }), _jsx(HiddenFromSearchBadge, {})] })) : (input), value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
39
+ if (!readOnly)
40
+ onChange(next ?? '');
41
+ }, searchable: true, clearable: true, placeholder: readOnly ? undefined : 'Select reference…', size: "sm" }), hidden?.noindex && (_jsx(HiddenPicksNote, { picks: [{ label: hidden.label, fieldLabel: hidden.noindex.fieldLabel }] }))] }))] }));
136
42
  };
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import type { ReferenceOptionSort } from '../../config/index.js';
3
+ import { type ReferenceOption } from './use-reference-options.js';
4
+ export interface ReferenceListFieldProps {
5
+ /** Base for the description's id. */
6
+ id: string;
7
+ label: string;
8
+ description?: string;
9
+ required?: boolean;
10
+ /** Offered as given; without them, options load from the API. */
11
+ options?: ReferenceOption[];
12
+ collections?: string[];
13
+ entryTypes?: string[];
14
+ displayField?: string;
15
+ branch?: string;
16
+ optionSort?: ReferenceOptionSort;
17
+ /** The stored items: ids, or a resolved read's `{ id, … }`. */
18
+ value: readonly unknown[];
19
+ /** Always given ids. */
20
+ onChange: (next: string[]) => void;
21
+ path: Array<string | number>;
22
+ /** The list's canopy path, which picks its comment control. */
23
+ canopyPath: string;
24
+ readOnly?: boolean;
25
+ /** Names the entry being edited; another entry starts with every row shut again. */
26
+ resetKey: string;
27
+ /** The most picks the list holds; the Add control gives way to a note at it. */
28
+ maxItems?: number;
29
+ /** The validation error on item `index`, if any. */
30
+ itemError?: (index: number) => string | undefined;
31
+ /** Called after Remove takes item `index` out, so the caller can offer Undo. */
32
+ onRemoved?: (index: number, id: string, title: string) => void;
33
+ }
34
+ /**
35
+ * A `list: true` reference field: one collapsible row per pick, in stored order, reordered by drag
36
+ * or the row menu. A row's body repoints that pick in place; an Add control under the rows appends
37
+ * and keeps focus, so several picks are several choices in a row. Neither control offers an entry
38
+ * another row holds, so no edit makes a duplicate. Comments attach to the whole field: a string
39
+ * item carries no item key for a thread to follow through a move.
40
+ */
41
+ export declare const ReferenceListField: React.FC<ReferenceListFieldProps>;