feat(ui): improve hasMany TextField UX (#10976)

### 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
This commit is contained in:
Tobias Odendahl
2025-02-06 12:02:55 +01:00
committed by GitHub
parent 694c76d51a
commit d8cfdc7bcb
6 changed files with 118 additions and 4 deletions

View File

@@ -13,6 +13,11 @@
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&--editable {
cursor: text;
outline: var(--accessibility-outline);
}
}
&:focus-visible {

View File

@@ -12,14 +12,17 @@ const baseClass = 'multi-value-label'
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (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 (
<div className={baseClass}>
<SelectComponents.MultiValueLabel
{...props}
innerProps={{
className: `${baseClass}__text`,
className,
...((editableProps && editableProps(data, className, props.selectProps)) || {}),
...(draggableProps || {}),
}}
/>

View File

@@ -8,8 +8,13 @@ type CustomSelectProps = {
DocumentDrawerToggler?: ReturnType<UseDocumentDrawer>[1]
draggableProps?: any
droppableRef?: React.RefObject<HTMLDivElement | null>
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

View File

@@ -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<TextInputProps> = (props) => {
const { i18n, t } = useTranslation()
const editableProps: ReactSelectAdapterProps['customProps']['editableProps'] = (
data,
className,
selectProps,
) => {
const editableClassName = `${className}--editable`
return {
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
event.currentTarget.contentEditable = 'false'
},
onClick: (event: React.MouseEvent<HTMLDivElement>) => {
event.currentTarget.contentEditable = 'true'
event.currentTarget.classList.add(editableClassName)
event.currentTarget.focus()
},
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
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 (
<div
className={[
@@ -73,15 +119,20 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
{hasMany ? (
<ReactSelect
className={`field-${path.replace(/\./g, '__')}`}
components={{ DropdownIndicator: null }}
customProps={{
editableProps,
}}
disabled={readOnly}
// prevent adding additional options if maxRows is reached
filterOption={() =>
!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) {

View File

@@ -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/)
})
})

View File

@@ -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',