Skip to content

Commit d8cfdc7

Browse files
authored
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
1 parent 694c76d commit d8cfdc7

File tree

6 files changed

+118
-4
lines changed

6 files changed

+118
-4
lines changed

packages/ui/src/elements/ReactSelect/MultiValueLabel/index.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
text-overflow: ellipsis;
1414
overflow: hidden;
1515
white-space: nowrap;
16+
17+
&--editable {
18+
cursor: text;
19+
outline: var(--accessibility-outline);
20+
}
1621
}
1722

1823
&:focus-visible {

packages/ui/src/elements/ReactSelect/MultiValueLabel/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ const baseClass = 'multi-value-label'
1212

1313
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
1414
// @ts-expect-error-next-line// TODO Fix this - moduleResolution 16 breaks our declare module
15-
const { selectProps: { customProps: { draggableProps } = {} } = {} } = props
15+
const { data, selectProps: { customProps: { draggableProps, editableProps } = {} } = {} } = props
16+
17+
const className = `${baseClass}__text`
1618

1719
return (
1820
<div className={baseClass}>
1921
<SelectComponents.MultiValueLabel
2022
{...props}
2123
innerProps={{
22-
className: `${baseClass}__text`,
24+
className,
25+
...((editableProps && editableProps(data, className, props.selectProps)) || {}),
2326
...(draggableProps || {}),
2427
}}
2528
/>

packages/ui/src/elements/ReactSelect/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ type CustomSelectProps = {
88
DocumentDrawerToggler?: ReturnType<UseDocumentDrawer>[1]
99
draggableProps?: any
1010
droppableRef?: React.RefObject<HTMLDivElement | null>
11+
editableProps?: (
12+
data: Option<{ label: string; value: string }>,
13+
className: string,
14+
selectProps: ReactSelectStateManagerProps,
15+
) => any
1116
onDelete?: DocumentDrawerProps['onDelete']
12-
onDocumentDrawerOpen: (args: {
17+
onDocumentDrawerOpen?: (args: {
1318
collectionSlug: string
1419
hasReadPermission: boolean
1520
id: number | string
@@ -87,6 +92,7 @@ export type ReactSelectAdapterProps = {
8792
isOptionSelected?: any
8893
isSearchable?: boolean
8994
isSortable?: boolean
95+
menuIsOpen?: boolean
9096
noOptionsMessage?: (obj: { inputValue: string }) => string
9197
numberOnly?: boolean
9298
onChange?: (value: Option | Option[]) => void

packages/ui/src/fields/Text/Input.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ChangeEvent } from 'react'
44
import { getTranslation } from '@payloadcms/translations'
55
import React from 'react'
66

7+
import type { ReactSelectAdapterProps } from '../../elements/ReactSelect/types.js'
78
import type { TextInputProps } from './types.js'
89

910
import { ReactSelect } from '../../elements/ReactSelect/index.js'
@@ -44,6 +45,51 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
4445

4546
const { i18n, t } = useTranslation()
4647

48+
const editableProps: ReactSelectAdapterProps['customProps']['editableProps'] = (
49+
data,
50+
className,
51+
selectProps,
52+
) => {
53+
const editableClassName = `${className}--editable`
54+
55+
return {
56+
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
57+
event.currentTarget.contentEditable = 'false'
58+
},
59+
onClick: (event: React.MouseEvent<HTMLDivElement>) => {
60+
event.currentTarget.contentEditable = 'true'
61+
event.currentTarget.classList.add(editableClassName)
62+
event.currentTarget.focus()
63+
},
64+
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
65+
if (event.key === 'Enter' || event.key === 'Tab' || event.key === 'Escape') {
66+
event.currentTarget.contentEditable = 'false'
67+
event.currentTarget.classList.remove(editableClassName)
68+
data.value.value = event.currentTarget.innerText
69+
data.label = event.currentTarget.innerText
70+
71+
if (data.value.value.replaceAll('\n', '')) {
72+
selectProps.onChange(selectProps.value, {
73+
action: 'create-option',
74+
option: data,
75+
})
76+
} else {
77+
if (Array.isArray(selectProps.value)) {
78+
const newValues = selectProps.value.filter((v) => v.id !== data.id)
79+
selectProps.onChange(newValues, {
80+
action: 'pop-value',
81+
removedValue: data,
82+
})
83+
}
84+
}
85+
86+
event.preventDefault()
87+
}
88+
event.stopPropagation()
89+
},
90+
}
91+
}
92+
4793
return (
4894
<div
4995
className={[
@@ -73,15 +119,20 @@ export const TextInput: React.FC<TextInputProps> = (props) => {
73119
{hasMany ? (
74120
<ReactSelect
75121
className={`field-${path.replace(/\./g, '__')}`}
122+
components={{ DropdownIndicator: null }}
123+
customProps={{
124+
editableProps,
125+
}}
76126
disabled={readOnly}
77127
// prevent adding additional options if maxRows is reached
78128
filterOption={() =>
79129
!maxRows ? true : !(Array.isArray(value) && maxRows && value.length >= maxRows)
80130
}
81-
isClearable
131+
isClearable={false}
82132
isCreatable
83133
isMulti
84134
isSortable
135+
menuIsOpen={false}
85136
noOptionsMessage={() => {
86137
const isOverHasMany = Array.isArray(value) && value.length >= maxRows
87138
if (isOverHasMany) {

test/fields/collections/Text/e2e.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,44 @@ describe('Text', () => {
240240
await expect(field.locator('.rs__value-container')).toContainText(input)
241241
await expect(field.locator('.rs__value-container')).toContainText(furtherInput)
242242
})
243+
244+
test('should allow editing hasMany text field values by clicking', async () => {
245+
const originalText = 'original'
246+
const newText = 'new'
247+
248+
await page.goto(url.create)
249+
250+
// fill required field
251+
const requiredField = page.locator('#field-text')
252+
await requiredField.fill(String(originalText))
253+
254+
const field = page.locator('.field-hasMany')
255+
256+
// Add initial value
257+
await field.click()
258+
await page.keyboard.type(originalText)
259+
await page.keyboard.press('Enter')
260+
261+
// Click to edit existing value
262+
const value = field.locator('.multi-value-label__text')
263+
await value.click()
264+
await value.dblclick()
265+
await page.keyboard.type(newText)
266+
await page.keyboard.press('Enter')
267+
268+
await saveDocAndAssert(page)
269+
await expect(field.locator('.rs__value-container')).toContainText(`${newText}`)
270+
})
271+
272+
test('should not allow editing hasMany text field values when disabled', async () => {
273+
await page.goto(url.create)
274+
const field = page.locator('.field-readOnlyHasMany')
275+
276+
// Try to click to edit
277+
const value = field.locator('.multi-value-label__text')
278+
await value.click({ force: true })
279+
280+
// Verify it does not become editable
281+
await expect(field.locator('.multi-value-label__text')).not.toHaveClass(/.*--editable/)
282+
})
243283
})

test/fields/collections/Text/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ const TextFields: CollectionConfig = {
127127
type: 'text',
128128
hasMany: true,
129129
},
130+
{
131+
name: 'readOnlyHasMany',
132+
type: 'text',
133+
hasMany: true,
134+
admin: {
135+
readOnly: true,
136+
},
137+
defaultValue: ['default'],
138+
},
130139
{
131140
name: 'validatesHasMany',
132141
type: 'text',

0 commit comments

Comments
 (0)