From d8cfdc7bcbd12144115b6e98a205b84005c9432c Mon Sep 17 00:00:00 2001 From: Tobias Odendahl Date: Thu, 6 Feb 2025 12:02:55 +0100 Subject: [PATCH] feat(ui): improve hasMany TextField UX (#10976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What? This updates the UX of `TextFields` with `hasMany: true` by: - Removing the dropdown menu and its indicator - Removing the ClearIndicator - Making text items directly editable ### Why? - The dropdown didn’t enhance usability. - The ClearIndicator removed all values at once with no way to undo, risking accidental data loss. Backspace still allows quick and intentional clearing. - Previously, text items could only be removed and re-added, but not edited inline. Allowing inline editing improves the editing experience. ### How? https://github.com/user-attachments/assets/02e8cc26-7faf-4444-baa1-39ce2b4547fa --- .../ReactSelect/MultiValueLabel/index.scss | 5 ++ .../ReactSelect/MultiValueLabel/index.tsx | 7 ++- packages/ui/src/elements/ReactSelect/types.ts | 8 ++- packages/ui/src/fields/Text/Input.tsx | 53 ++++++++++++++++++- test/fields/collections/Text/e2e.spec.ts | 40 ++++++++++++++ test/fields/collections/Text/index.ts | 9 ++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.scss b/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.scss index 22b529482..4b80f2b90 100644 --- a/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.scss +++ b/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.scss @@ -13,6 +13,11 @@ text-overflow: ellipsis; overflow: hidden; white-space: nowrap; + + &--editable { + cursor: text; + outline: var(--accessibility-outline); + } } &:focus-visible { diff --git a/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.tsx b/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.tsx index 301d4a376..593a1a24f 100644 --- a/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.tsx +++ b/packages/ui/src/elements/ReactSelect/MultiValueLabel/index.tsx @@ -12,14 +12,17 @@ const baseClass = 'multi-value-label' export const MultiValueLabel: React.FC> = (props) => { // @ts-expect-error-next-line// TODO Fix this - moduleResolution 16 breaks our declare module - const { selectProps: { customProps: { draggableProps } = {} } = {} } = props + const { data, selectProps: { customProps: { draggableProps, editableProps } = {} } = {} } = props + + const className = `${baseClass}__text` return (
diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index 1b0ad30d6..4520327b0 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -8,8 +8,13 @@ type CustomSelectProps = { DocumentDrawerToggler?: ReturnType[1] draggableProps?: any droppableRef?: React.RefObject + editableProps?: ( + data: Option<{ label: string; value: string }>, + className: string, + selectProps: ReactSelectStateManagerProps, + ) => any onDelete?: DocumentDrawerProps['onDelete'] - onDocumentDrawerOpen: (args: { + onDocumentDrawerOpen?: (args: { collectionSlug: string hasReadPermission: boolean id: number | string @@ -87,6 +92,7 @@ export type ReactSelectAdapterProps = { isOptionSelected?: any isSearchable?: boolean isSortable?: boolean + menuIsOpen?: boolean noOptionsMessage?: (obj: { inputValue: string }) => string numberOnly?: boolean onChange?: (value: Option | Option[]) => void diff --git a/packages/ui/src/fields/Text/Input.tsx b/packages/ui/src/fields/Text/Input.tsx index bc03c85bf..b91d0ebc5 100644 --- a/packages/ui/src/fields/Text/Input.tsx +++ b/packages/ui/src/fields/Text/Input.tsx @@ -4,6 +4,7 @@ import type { ChangeEvent } from 'react' import { getTranslation } from '@payloadcms/translations' import React from 'react' +import type { ReactSelectAdapterProps } from '../../elements/ReactSelect/types.js' import type { TextInputProps } from './types.js' import { ReactSelect } from '../../elements/ReactSelect/index.js' @@ -44,6 +45,51 @@ export const TextInput: React.FC = (props) => { const { i18n, t } = useTranslation() + const editableProps: ReactSelectAdapterProps['customProps']['editableProps'] = ( + data, + className, + selectProps, + ) => { + const editableClassName = `${className}--editable` + + return { + onBlur: (event: React.FocusEvent) => { + event.currentTarget.contentEditable = 'false' + }, + onClick: (event: React.MouseEvent) => { + event.currentTarget.contentEditable = 'true' + event.currentTarget.classList.add(editableClassName) + event.currentTarget.focus() + }, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === 'Tab' || event.key === 'Escape') { + event.currentTarget.contentEditable = 'false' + event.currentTarget.classList.remove(editableClassName) + data.value.value = event.currentTarget.innerText + data.label = event.currentTarget.innerText + + if (data.value.value.replaceAll('\n', '')) { + selectProps.onChange(selectProps.value, { + action: 'create-option', + option: data, + }) + } else { + if (Array.isArray(selectProps.value)) { + const newValues = selectProps.value.filter((v) => v.id !== data.id) + selectProps.onChange(newValues, { + action: 'pop-value', + removedValue: data, + }) + } + } + + event.preventDefault() + } + event.stopPropagation() + }, + } + } + return (
= (props) => { {hasMany ? ( !maxRows ? true : !(Array.isArray(value) && maxRows && value.length >= maxRows) } - isClearable + isClearable={false} isCreatable isMulti isSortable + menuIsOpen={false} noOptionsMessage={() => { const isOverHasMany = Array.isArray(value) && value.length >= maxRows if (isOverHasMany) { diff --git a/test/fields/collections/Text/e2e.spec.ts b/test/fields/collections/Text/e2e.spec.ts index 26fe940e9..68ab222e5 100644 --- a/test/fields/collections/Text/e2e.spec.ts +++ b/test/fields/collections/Text/e2e.spec.ts @@ -240,4 +240,44 @@ describe('Text', () => { await expect(field.locator('.rs__value-container')).toContainText(input) await expect(field.locator('.rs__value-container')).toContainText(furtherInput) }) + + test('should allow editing hasMany text field values by clicking', async () => { + const originalText = 'original' + const newText = 'new' + + await page.goto(url.create) + + // fill required field + const requiredField = page.locator('#field-text') + await requiredField.fill(String(originalText)) + + const field = page.locator('.field-hasMany') + + // Add initial value + await field.click() + await page.keyboard.type(originalText) + await page.keyboard.press('Enter') + + // Click to edit existing value + const value = field.locator('.multi-value-label__text') + await value.click() + await value.dblclick() + await page.keyboard.type(newText) + await page.keyboard.press('Enter') + + await saveDocAndAssert(page) + await expect(field.locator('.rs__value-container')).toContainText(`${newText}`) + }) + + test('should not allow editing hasMany text field values when disabled', async () => { + await page.goto(url.create) + const field = page.locator('.field-readOnlyHasMany') + + // Try to click to edit + const value = field.locator('.multi-value-label__text') + await value.click({ force: true }) + + // Verify it does not become editable + await expect(field.locator('.multi-value-label__text')).not.toHaveClass(/.*--editable/) + }) }) diff --git a/test/fields/collections/Text/index.ts b/test/fields/collections/Text/index.ts index f850e71c8..fb22b4653 100644 --- a/test/fields/collections/Text/index.ts +++ b/test/fields/collections/Text/index.ts @@ -127,6 +127,15 @@ const TextFields: CollectionConfig = { type: 'text', hasMany: true, }, + { + name: 'readOnlyHasMany', + type: 'text', + hasMany: true, + admin: { + readOnly: true, + }, + defaultValue: ['default'], + }, { name: 'validatesHasMany', type: 'text',