canopycms 0.4.0 → 0.6.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.
- package/CHANGELOG.md +78 -42
- package/dist/ai/generate.d.ts +3 -0
- package/dist/ai/generate.js +12 -29
- package/dist/ai/handler.js +1 -0
- package/dist/ai/types.d.ts +3 -9
- package/dist/api/reference-options.d.ts +2 -5
- package/dist/api/reference-options.js +21 -2
- package/dist/api/schema.d.ts +4 -4
- package/dist/build/generate-ai-content.js +1 -0
- package/dist/cli/cli.js +136 -39
- package/dist/cli/generate-ai-content.js +75 -32
- package/dist/cli/init.js +34 -5
- package/dist/cli/template-files/cms-stack.ts.template +3 -0
- package/dist/cli/templates.js +5 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +74 -0
- package/dist/config/schemas/config.js +21 -0
- package/dist/config/schemas/field.d.ts +30 -0
- package/dist/config/schemas/field.js +22 -0
- package/dist/config/types.d.ts +39 -0
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +33 -0
- package/dist/editor/FormRenderer.js +15 -13
- package/dist/editor/copy.d.ts +5 -0
- package/dist/editor/copy.js +5 -0
- package/dist/editor/fields/BlockField.d.ts +2 -0
- package/dist/editor/fields/BlockField.js +15 -8
- package/dist/editor/fields/ItemList.d.ts +21 -3
- package/dist/editor/fields/ItemList.js +16 -8
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +6 -2
- package/dist/editor/fields/ObjectListField.js +9 -3
- package/dist/editor/fields/ReferenceField.d.ts +6 -7
- package/dist/editor/fields/ReferenceField.js +28 -122
- package/dist/editor/fields/ReferenceListField.d.ts +41 -0
- package/dist/editor/fields/ReferenceListField.js +139 -0
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +6 -3
- package/dist/editor/fields/StringListField.d.ts +10 -1
- package/dist/editor/fields/StringListField.js +18 -8
- package/dist/editor/fields/item-expansion.d.ts +7 -3
- package/dist/editor/fields/item-expansion.js +6 -4
- package/dist/editor/fields/list-item-keys.d.ts +6 -5
- package/dist/editor/fields/list-item-keys.js +45 -13
- package/dist/editor/fields/reference-noindex.d.ts +37 -0
- package/dist/editor/fields/reference-noindex.js +48 -0
- package/dist/editor/fields/use-reference-options.d.ts +36 -0
- package/dist/editor/fields/use-reference-options.js +121 -0
- package/dist/editor/form-outline.d.ts +2 -2
- package/dist/editor/form-outline.js +4 -2
- package/dist/editor/theme.js +5 -0
- package/dist/entry-schema-registry.d.ts +1 -1
- package/dist/entry-schema-registry.js +3 -2
- package/dist/entry-schema.d.ts +5 -3
- package/dist/entry-schema.js +2 -1
- package/dist/reference-resolver.d.ts +18 -1
- package/dist/reference-resolver.js +175 -8
- package/dist/schema/schema-store.d.ts +2 -2
- package/dist/static/index.d.ts +8 -4
- package/dist/static/index.js +38 -13
- package/dist/static/seo.d.ts +7 -5
- package/dist/static/seo.js +13 -6
- package/dist/validation/entry-validator.d.ts +5 -0
- package/dist/validation/entry-validator.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway.js +2 -2
- package/package.json +1 -1
package/dist/config/types.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { LogicalPath, ContentId } from '../paths/types.js';
|
|
|
9
9
|
import type { PermissionPath } from '../authorization/types.js';
|
|
10
10
|
import type { EntryLinkUrlResolver } from '../entry-link-resolver.js';
|
|
11
11
|
import type { CropRect } from '../assets/transform-directives.js';
|
|
12
|
+
import type { SeoFieldLocation } from '../static/seo.js';
|
|
12
13
|
declare const scalarFieldTypes: readonly ["string", "number", "boolean", "datetime", "code"];
|
|
13
14
|
export declare const markdownFieldTypes: readonly ["markdown", "mdx"];
|
|
14
15
|
export declare const primitiveFieldTypes: readonly ["string", "number", "boolean", "datetime", "code", "markdown", "mdx"];
|
|
@@ -52,6 +53,13 @@ interface BaseFieldConfig {
|
|
|
52
53
|
* `includeBody: true`.
|
|
53
54
|
*/
|
|
54
55
|
isBody?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* On a `list: true` field or a `block` field: the most items the list may hold. The editor
|
|
58
|
+
* stops adding at the cap, saving over it is refused, and a static build fails on an entry
|
|
59
|
+
* over it, so adding a cap to a list that already holds more fails the build until the
|
|
60
|
+
* content is fixed. `ensureMaxItemsPlacement` (`validation.ts`) refuses it elsewhere in a registry schema.
|
|
61
|
+
*/
|
|
62
|
+
maxItems?: number;
|
|
55
63
|
}
|
|
56
64
|
interface PrimitiveFieldConfig extends BaseFieldConfig {
|
|
57
65
|
type: (typeof scalarFieldTypes)[number];
|
|
@@ -136,7 +144,29 @@ export interface ReferenceFieldConfig extends BaseFieldConfig {
|
|
|
136
144
|
* to different shapes in different places.
|
|
137
145
|
*/
|
|
138
146
|
includeBody?: boolean;
|
|
147
|
+
/** Order of the picker's options. Default `'label'`. See {@link ReferenceOptionSort}. */
|
|
148
|
+
optionSort?: ReferenceOptionSort;
|
|
139
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* How a reference picker orders its options. Every mode runs after the search filter.
|
|
152
|
+
*
|
|
153
|
+
* - `'label'`: alphabetical by label.
|
|
154
|
+
* - `'collection'`: one group per collection, in content-tree order (a collection, then its
|
|
155
|
+
* subcollections), each in its collection's own order, then by label.
|
|
156
|
+
* - `{ field, direction }`: by the target's value at `field`, a dotted path for a nested field
|
|
157
|
+
* (`'meta.publishedAt'`). Numbers compare as numbers and sort before strings (`'desc'` reverses that too); strings compare
|
|
158
|
+
* as plain text, so point it at an ISO date (`YYYY-MM-DD`; unquoted frontmatter dates count):
|
|
159
|
+
* a display date like `'March 4'` sorts by month name. An entry with no value sorts first
|
|
160
|
+
* under `'desc'`, last under `'asc'` (the default). Ties go by label.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* // Newest first, with entries not yet given a date at the top.
|
|
164
|
+
* optionSort: { field: 'publishedAt', direction: 'desc' }
|
|
165
|
+
*/
|
|
166
|
+
export type ReferenceOptionSort = 'label' | 'collection' | {
|
|
167
|
+
field: string;
|
|
168
|
+
direction?: 'asc' | 'desc';
|
|
169
|
+
};
|
|
140
170
|
/**
|
|
141
171
|
* Image field config. The value is a structured object (see `ImageFieldValue`
|
|
142
172
|
* below), not a bare URL string.
|
|
@@ -460,6 +490,8 @@ export interface CanopyConfig {
|
|
|
460
490
|
validateEntry?: ValidateEntryHook;
|
|
461
491
|
/** Site default for every field checked as MDX — see {@link CanopyConfigInput.mdxAllow}. */
|
|
462
492
|
mdxAllow?: MdxAllowlist;
|
|
493
|
+
/** Where entries keep their SEO fields — see {@link CanopyConfigInput.seo}. */
|
|
494
|
+
seo?: SeoFieldLocation;
|
|
463
495
|
/** Dev-mode-only behavior — see {@link CanopyConfigInput.dev}. */
|
|
464
496
|
dev?: DevConfig;
|
|
465
497
|
}
|
|
@@ -549,6 +581,13 @@ export interface CanopyConfigInput {
|
|
|
549
581
|
validateEntry?: ValidateEntryHook;
|
|
550
582
|
/** What every field checked as MDX accepts; a field's own `mdxAllow` replaces it key by key. */
|
|
551
583
|
mdxAllow?: MdxAllowlist;
|
|
584
|
+
/**
|
|
585
|
+
* Where entries keep their SEO fields: `group` when `defineSeoFieldGroup({ group })` nests
|
|
586
|
+
* them, `fields` for renamed ones. Omit for the flat default. Every surface that reads an
|
|
587
|
+
* entry's `noindex` reads it here: the sitemap and page metadata helpers `createNextCanopyContext`
|
|
588
|
+
* returns, AI content and the editor's reference picker.
|
|
589
|
+
*/
|
|
590
|
+
seo?: SeoFieldLocation;
|
|
552
591
|
/** Dev-mode-only behavior (content-sync divergence detection). Ignored when mode !== 'dev'. */
|
|
553
592
|
dev?: DevConfig;
|
|
554
593
|
}
|
|
@@ -62,6 +62,12 @@ export declare const ensureNoGroupsInsideComplexFields: (fields: unknown) => voi
|
|
|
62
62
|
* never be saved empty, so it would never be stamped. Throws on the first problem.
|
|
63
63
|
*/
|
|
64
64
|
export declare const ensureStampOnPublishPlacement: (fields: unknown, scopeLabel?: string) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Check that `maxItems` is a positive whole number on a field that holds items: a `list: true`
|
|
67
|
+
* field or a `block` field. Recurses into groups, objects and block templates like
|
|
68
|
+
* `ensureStampOnPublishPlacement`. Throws on the first problem.
|
|
69
|
+
*/
|
|
70
|
+
export declare const ensureMaxItemsPlacement: (fields: unknown, scopeLabel?: string) => void;
|
|
65
71
|
/**
|
|
66
72
|
* Check every markdown and mdx field's `mdxAllow` against its schema and its `renderAs`,
|
|
67
73
|
* `mdxAllow` and `executable` against each other. Throws on the first problem.
|
|
@@ -294,6 +294,39 @@ export const ensureStampOnPublishPlacement = (fields, scopeLabel = 'entry schema
|
|
|
294
294
|
};
|
|
295
295
|
check(fields, undefined);
|
|
296
296
|
};
|
|
297
|
+
/**
|
|
298
|
+
* Check that `maxItems` is a positive whole number on a field that holds items: a `list: true`
|
|
299
|
+
* field or a `block` field. Recurses into groups, objects and block templates like
|
|
300
|
+
* `ensureStampOnPublishPlacement`. Throws on the first problem.
|
|
301
|
+
*/
|
|
302
|
+
export const ensureMaxItemsPlacement = (fields, scopeLabel = 'entry schema') => {
|
|
303
|
+
const refuse = (f, why) => {
|
|
304
|
+
throw new Error(`${scopeLabel}: field "${f.name ?? 'unknown'}" has maxItems, ${why}`);
|
|
305
|
+
};
|
|
306
|
+
const check = (scopeFields) => {
|
|
307
|
+
if (!Array.isArray(scopeFields))
|
|
308
|
+
return;
|
|
309
|
+
for (const field of scopeFields) {
|
|
310
|
+
const f = field;
|
|
311
|
+
if (f?.maxItems !== undefined) {
|
|
312
|
+
if (typeof f.maxItems !== 'number' || !Number.isInteger(f.maxItems) || f.maxItems < 1) {
|
|
313
|
+
refuse(f, 'which must be a positive whole number');
|
|
314
|
+
}
|
|
315
|
+
if (f.list !== true && f.type !== 'block') {
|
|
316
|
+
refuse(f, 'but it is not a list; only a `list: true` field or a block field holds items');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (f?.type === 'group' || f?.type === 'object')
|
|
320
|
+
check(f.fields);
|
|
321
|
+
if (f?.type === 'block' && Array.isArray(f.templates)) {
|
|
322
|
+
for (const template of f.templates) {
|
|
323
|
+
check(template.fields);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
check(fields);
|
|
329
|
+
};
|
|
297
330
|
/**
|
|
298
331
|
* Check every markdown and mdx field's `mdxAllow` against its schema and its `renderAs`,
|
|
299
332
|
* `mdxAllow` and `executable` against each other. Throws on the first problem.
|
|
@@ -16,6 +16,7 @@ import { DateTimeField } from './fields/DateTimeField.js';
|
|
|
16
16
|
import { BlockField } from './fields/BlockField.js';
|
|
17
17
|
import { SelectField } from './fields/SelectField.js';
|
|
18
18
|
import { ReferenceField } from './fields/ReferenceField.js';
|
|
19
|
+
import { ReferenceListField } from './fields/ReferenceListField.js';
|
|
19
20
|
import { CodeField } from './fields/CodeField.js';
|
|
20
21
|
import { ImageField } from './fields/ImageField.js';
|
|
21
22
|
import { ObjectField } from './fields/ObjectField.js';
|
|
@@ -201,7 +202,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
201
202
|
switch (field.type) {
|
|
202
203
|
case 'string':
|
|
203
204
|
if (field.list) {
|
|
204
|
-
return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
205
|
+
return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: field.maxItems }, fieldKey(path)));
|
|
205
206
|
}
|
|
206
207
|
return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
207
208
|
case 'boolean':
|
|
@@ -210,7 +211,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
210
211
|
if (field.list) {
|
|
211
212
|
return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue)
|
|
212
213
|
? currentValue.filter((v) => typeof v === 'number')
|
|
213
|
-
: [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
214
|
+
: [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: field.maxItems }, fieldKey(path)));
|
|
214
215
|
}
|
|
215
216
|
return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
216
217
|
case 'datetime':
|
|
@@ -229,22 +230,23 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
229
230
|
? Array.isArray(currentValue)
|
|
230
231
|
? currentValue
|
|
231
232
|
: []
|
|
232
|
-
: (currentValue ?? ''), multiple: isMulti, onChange: (next) => update(next), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
233
|
+
: (currentValue ?? ''), multiple: isMulti, onChange: (next) => update(next), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: isMulti ? selectField.maxItems : undefined }, fieldKey(path)));
|
|
233
234
|
}
|
|
234
235
|
case 'reference': {
|
|
235
236
|
const referenceField = field;
|
|
236
237
|
const staticOptions = referenceField.options
|
|
237
238
|
? normalizeOptions(referenceField.options)
|
|
238
239
|
: undefined;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
240
|
+
if (referenceField.list) {
|
|
241
|
+
return wrapWithComments(_jsx(ReferenceListField, { id: fieldId, label: label, description: field.description, required: field.required, options: staticOptions, collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, optionSort: referenceField.optionSort, value: Array.isArray(currentValue) ? currentValue : [], onChange: (next) => update(next), path: path, canopyPath: canopyPath, readOnly: readOnly, resetKey: boundaryResetKey, maxItems: referenceField.maxItems, itemError: (index) => fieldErrors?.[normalizeCanopyPath([...path, index])], onRemoved: (index, id, title) => onRemoved?.({
|
|
242
|
+
kind: 'list-item',
|
|
243
|
+
listPath: path,
|
|
244
|
+
index,
|
|
245
|
+
item: id,
|
|
246
|
+
label: `"${title}" from ${label}`,
|
|
247
|
+
}) }, fieldKey(path)));
|
|
248
|
+
}
|
|
249
|
+
return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions, collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, optionSort: referenceField.optionSort, value: currentValue ?? '', onChange: (next) => update(next), dataCanopyField: canopyPath, readOnly: readOnly }, fieldKey(path)));
|
|
248
250
|
}
|
|
249
251
|
case 'image': {
|
|
250
252
|
const imageField = field;
|
|
@@ -256,7 +258,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
256
258
|
}
|
|
257
259
|
case 'block': {
|
|
258
260
|
const blockField = field;
|
|
259
|
-
return wrapWithComments(_jsx(BlockField, { label: label, required: field.required, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, resetKey: boundaryResetKey, hasErrors: rowHasErrors(path), onRemoved: (index, block) => {
|
|
261
|
+
return wrapWithComments(_jsx(BlockField, { label: label, required: field.required, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, resetKey: boundaryResetKey, hasErrors: rowHasErrors(path), maxItems: blockField.maxItems, onRemoved: (index, block) => {
|
|
260
262
|
const template = blockField.templates.find((t) => t.name === block.template);
|
|
261
263
|
onRemoved?.({
|
|
262
264
|
kind: 'list-item',
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -19,3 +19,8 @@ export declare const EDITOR_ACTIONS: {
|
|
|
19
19
|
readonly changeUrl: "Change URL";
|
|
20
20
|
readonly changeSlug: "Change slug";
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
24
|
+
* `one` and `many` name what the list holds.
|
|
25
|
+
*/
|
|
26
|
+
export declare const listFullReason: (maxItems: number, one?: string, many?: string) => string;
|
package/dist/editor/copy.js
CHANGED
|
@@ -19,3 +19,8 @@ export const EDITOR_ACTIONS = {
|
|
|
19
19
|
changeUrl: 'Change URL',
|
|
20
20
|
changeSlug: 'Change slug',
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
24
|
+
* `one` and `many` name what the list holds.
|
|
25
|
+
*/
|
|
26
|
+
export const listFullReason = (maxItems, one = 'item', many = 'items') => `This list holds at most ${maxItems} ${maxItems === 1 ? one : many}. Remove one to add another.`;
|
|
@@ -26,6 +26,8 @@ export interface BlockFieldProps {
|
|
|
26
26
|
hasErrors?: (index: number) => boolean;
|
|
27
27
|
/** Told of each block Remove takes out, so the caller can offer Undo. */
|
|
28
28
|
onRemoved?: (index: number, block: BlockInstance) => void;
|
|
29
|
+
/** The most blocks the list holds; at it, every add control is disabled and says why. */
|
|
30
|
+
maxItems?: number;
|
|
29
31
|
}
|
|
30
32
|
export declare const BlockField: React.FC<BlockFieldProps>;
|
|
31
33
|
export {};
|
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
} }),
|
|
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:
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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;
|