From af35da41ed178107da1502b979317edd52d6c3b7 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Fri, 27 Mar 2026 09:30:37 -0700 Subject: [PATCH] feat: add `variant: list` to `schema.multiselect` - create an alternative to mantine's MultiSelecft - supports inline editing, deletion, reordering, etc. - appears like a minimal version of the normal array field --- .changeset/strong-hotels-hunt.md | 5 + docs/root-cms.d.ts | 4 + .../TemplateSandbox/TemplateSandbox.schema.ts | 13 ++ packages/root-cms/core/schema.ts | 6 + .../fields/MultiSelectField.test.tsx | 122 +++++++++++++ .../DocEditor/fields/MultiSelectField.tsx | 12 ++ .../DocEditor/fields/StringListField.css | 62 +++++++ .../DocEditor/fields/StringListField.tsx | 166 ++++++++++++++++++ 8 files changed, 390 insertions(+) create mode 100644 .changeset/strong-hotels-hunt.md create mode 100644 packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.test.tsx create mode 100644 packages/root-cms/ui/components/DocEditor/fields/StringListField.css create mode 100644 packages/root-cms/ui/components/DocEditor/fields/StringListField.tsx diff --git a/.changeset/strong-hotels-hunt.md b/.changeset/strong-hotels-hunt.md new file mode 100644 index 000000000..1e4d0a19e --- /dev/null +++ b/.changeset/strong-hotels-hunt.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root-cms': patch +--- + +feat: add `variant: list` to `schema.multiselect` diff --git a/docs/root-cms.d.ts b/docs/root-cms.d.ts index 2ea5959b2..10039374b 100644 --- a/docs/root-cms.d.ts +++ b/docs/root-cms.d.ts @@ -371,6 +371,10 @@ export interface TemplateSandboxFields { datetimeWithTimezone?: number; /** DateField */ date?: string; + /** MultiSelect */ + multiselect?: string[]; + /** MultiSelect (String List) */ + multiselectStringList?: string[]; /** StringField (Textarea) */ string?: string; /** StringField (JSON) */ diff --git a/docs/templates/TemplateSandbox/TemplateSandbox.schema.ts b/docs/templates/TemplateSandbox/TemplateSandbox.schema.ts index e87c5177d..72bf23f91 100644 --- a/docs/templates/TemplateSandbox/TemplateSandbox.schema.ts +++ b/docs/templates/TemplateSandbox/TemplateSandbox.schema.ts @@ -36,6 +36,19 @@ export default schema.define({ id: 'date', label: 'DateField', }), + schema.multiselect({ + id: 'multiselect', + label: 'MultiSelect', + translate: true, + creatable: true, + }), + schema.multiselect({ + id: 'multiselectStringList', + label: 'MultiSelect (String List)', + translate: true, + variant: 'list', + creatable: true, + }), schema.string({ id: 'string', label: 'StringField (Textarea)', diff --git a/packages/root-cms/core/schema.ts b/packages/root-cms/core/schema.ts index 53704cc8d..2691baeb1 100644 --- a/packages/root-cms/core/schema.ts +++ b/packages/root-cms/core/schema.ts @@ -120,6 +120,12 @@ export type MultiSelectField = Omit & { /** Set to `true` to allow users to create arbitrary values. */ creatable?: boolean; translate?: boolean; + /** + * The UI variant to use. `multiselect` renders as a dropdown with search and + * selection capabilities. `list` renders as a list of text inputs with + * drag-and-drop reordering. + */ + variant?: 'multiselect' | 'list'; }; export function multiselect( diff --git a/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.test.tsx b/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.test.tsx new file mode 100644 index 000000000..b040dd21d --- /dev/null +++ b/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.test.tsx @@ -0,0 +1,122 @@ +import {render} from '@testing-library/preact'; +import {describe, it, vi, expect} from 'vitest'; +import {MultiSelectField} from './MultiSelectField.js'; + +const setValueMock = vi.fn(); +const useDraftDocValueMock = vi.fn(); + +vi.mock('../../../hooks/useDraftDoc.js', () => ({ + useDraftDocValue: (key: string, defaultValue: unknown) => + useDraftDocValueMock(key, defaultValue), +})); + +// Stub drag-and-drop so StringListField renders without errors. +vi.mock('@hello-pangea/dnd', () => ({ + DragDropContext: ({children}: any) => children, + Droppable: ({children}: any) => + children({innerRef: () => {}, droppableProps: {}}, {}), + Draggable: ({children}: any) => + children( + {innerRef: () => {}, draggableProps: {}, dragHandleProps: {}}, + {isDragging: false} + ), +})); + +// Stub Mantine components that require MantineProvider context. +vi.mock('@mantine/core', () => ({ + MultiSelect: (props: any) => ( + + ), + ActionIcon: ({children, ...props}: any) => ( + + ), + Button: ({children, ...props}: any) => , + Tooltip: ({children}: any) => children, +})); + +describe('MultiSelectField serialization', () => { + const sampleData: string[] = ['alpha', 'beta', 'gamma']; + + it('multiselect variant reads string[] from draft doc', () => { + useDraftDocValueMock.mockReturnValue([sampleData, setValueMock]); + + render( + + ); + + expect(useDraftDocValueMock).toHaveBeenCalledWith('fields.tags', []); + }); + + it('list variant reads string[] from draft doc', () => { + useDraftDocValueMock.mockReturnValue([sampleData, setValueMock]); + + render( + + ); + + expect(useDraftDocValueMock).toHaveBeenCalledWith('fields.tags', []); + }); + + it('both variants use the same default value', () => { + useDraftDocValueMock.mockReturnValue([[], setValueMock]); + + const calls: unknown[][] = []; + + // Render multiselect variant. + render( + + ); + calls.push(useDraftDocValueMock.mock.calls.at(-1)!); + + // Render list variant. + render( + + ); + calls.push(useDraftDocValueMock.mock.calls.at(-1)!); + + // Both should pass the same (key, default) to useDraftDocValue. + expect(calls[0]).toEqual(calls[1]); + }); + + it('list variant writes string[] via setValue', () => { + useDraftDocValueMock.mockReturnValue([sampleData, setValueMock]); + setValueMock.mockClear(); + + const {container} = render( + + ); + + // Simulate typing in the first input. + const input = container.querySelector('input') as HTMLInputElement; + expect(input).not.toBeNull(); + input.value = 'alpha-edited'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + // setValue should have been called with a string[]. + expect(setValueMock).toHaveBeenCalled(); + const written = setValueMock.mock.calls[0][0]; + expect(Array.isArray(written)).toBe(true); + written.forEach((v: unknown) => expect(typeof v).toBe('string')); + }); +}); diff --git a/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx b/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx index 126afbf84..c84705851 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx @@ -3,9 +3,21 @@ import {useMemo} from 'preact/hooks'; import * as schema from '../../../../core/schema.js'; import {useDraftDocValue} from '../../../hooks/useDraftDoc.js'; import {FieldProps} from './FieldProps.js'; +import {StringListField} from './StringListField.js'; export function MultiSelectField(props: FieldProps) { const field = props.field as schema.MultiSelectField; + + if (field.variant === 'list') { + return ; + } + + return ; +} + +/** Default multiselect variant using the Mantine MultiSelect dropdown. */ +function DefaultMultiSelectField(props: FieldProps) { + const field = props.field as schema.MultiSelectField; const [value, setValue] = useDraftDocValue(props.deepKey, []); const options = useMemo(() => { diff --git a/packages/root-cms/ui/components/DocEditor/fields/StringListField.css b/packages/root-cms/ui/components/DocEditor/fields/StringListField.css new file mode 100644 index 000000000..58b57b81c --- /dev/null +++ b/packages/root-cms/ui/components/DocEditor/fields/StringListField.css @@ -0,0 +1,62 @@ +.StringListField__items { + display: flex; + flex-direction: column; + gap: 0; +} + +.StringListField__item { + display: flex; + align-items: center; + gap: 4px; + border-bottom: 1px solid var(--color-border); +} + +.StringListField__item:first-child { + border-top: 1px solid var(--color-border); +} + +.StringListField__item--dragging { + background-color: #f8f9fa; + border: 1px solid #dedede; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 0 10px 15px -5px, + rgba(0, 0, 0, 0.04) 0 7px 7px -5px; +} + +.StringListField__item__handle { + display: flex; + align-items: center; + padding: 0 4px; + color: var(--color-text-subtle); + cursor: grab; +} + +.StringListField__item__handle:active { + cursor: grabbing; +} + +.StringListField__item__input { + flex: 1; +} + +.StringListField__item__input input { + width: 100%; + border: none; + background: transparent; + padding: 6px 4px; + font-size: 13px; + font-family: inherit; + outline: none; +} + +.StringListField__item__input input:focus { + background: var(--color-bg-subtle); +} + +.StringListField__item__remove { + padding: 0 4px; +} + +.StringListField__add { + margin-top: 8px; +} diff --git a/packages/root-cms/ui/components/DocEditor/fields/StringListField.tsx b/packages/root-cms/ui/components/DocEditor/fields/StringListField.tsx new file mode 100644 index 000000000..41d078e5c --- /dev/null +++ b/packages/root-cms/ui/components/DocEditor/fields/StringListField.tsx @@ -0,0 +1,166 @@ +import './StringListField.css'; + +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from '@hello-pangea/dnd'; +import {ActionIcon, Button, Tooltip} from '@mantine/core'; +import {IconGripVertical, IconTrash} from '@tabler/icons-preact'; +import {useRef} from 'preact/hooks'; +import * as schema from '../../../../core/schema.js'; +import {useDraftDocValue} from '../../../hooks/useDraftDoc.js'; +import {joinClassNames} from '../../../utils/classes.js'; +import {FieldProps} from './FieldProps.js'; + +/** + * Renders a multiselect field as a list of text inputs with drag-and-drop + * reordering. Data is stored as a `string[]`, identical to the default + * MultiSelect variant. + */ +export function StringListField(props: FieldProps) { + const field = props.field as schema.MultiSelectField; + const [value, setValue] = useDraftDocValue(props.deepKey, []); + const inputRefs = useRef>({}); + + function updateItem(index: number, text: string) { + const next = [...(value || [])]; + next[index] = text; + setValue(next); + } + + function removeItem(index: number) { + const next = [...(value || [])]; + next.splice(index, 1); + setValue(next.length > 0 ? next : (null as any)); + } + + function addItem() { + const next = [...(value || []), '']; + setValue(next); + // Focus the new input after render. + requestAnimationFrame(() => { + const el = inputRefs.current[next.length - 1]; + if (el) { + el.focus(); + } + }); + } + + function onDragEnd(result: DropResult) { + const {destination, source} = result; + if (!destination || destination.index === source.index) { + return; + } + const next = [...(value || [])]; + const [removed] = next.splice(source.index, 1); + next.splice(destination.index, 0, removed); + setValue(next); + } + + function onKeyDown(e: KeyboardEvent, index: number) { + if (e.key === 'Enter') { + e.preventDefault(); + addItem(); + } else if ( + e.key === 'Backspace' && + (value || [])[index] === '' && + (value || []).length > 1 + ) { + e.preventDefault(); + removeItem(index); + // Focus the previous input. + requestAnimationFrame(() => { + const prevIndex = Math.max(0, index - 1); + const el = inputRefs.current[prevIndex]; + if (el) { + el.focus(); + } + }); + } + } + + const items = value || []; + + return ( +
+ {items.length > 0 && ( + + + {(provided: any) => ( +
+ {items.map((item, index) => ( + + {(provided, snapshot) => ( +
+
+ +
+
+ { + inputRefs.current[index] = el; + }} + type="text" + value={item} + placeholder={field.placeholder || 'Enter value'} + onInput={(e) => { + updateItem( + index, + (e.target as HTMLInputElement).value + ); + }} + onKeyDown={(e) => onKeyDown(e, index)} + /> +
+
+ + removeItem(index)} + > + + + +
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ )} +
+ +
+
+ ); +}