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:
@@ -13,6 +13,11 @@
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&--editable {
|
||||
cursor: text;
|
||||
outline: var(--accessibility-outline);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -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 || {}),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user