Files
payloadcms/packages/ui/src/elements/ReactSelect/index.tsx
Tobias Odendahl e5683913b4 feat(ui): make select and relationship field placeholder configurable (#12253)
### What?
Allows to overwrite the default placeholder text of select and
relationship fields.

### Why?
The default placeholder text is generic. In some scenarios a custom
placeholder can guide the user better.

### How?
Adds a new property `admin.placeholder` to relationship and select field
which allows to define an alternative text or translation function for
the placeholder. The placeholder is used in the form fields as well as
in the filter options.

![Screenshot 2025-04-29 at 15 28
54](https://github.com/user-attachments/assets/d83d60c8-d4f6-41b7-951c-9f21c238afd8)
![Screenshot 2025-04-29 at 15 28
19](https://github.com/user-attachments/assets/d2263cf1-6042-4072-b5a9-e10af5f380bb)

---------

Co-authored-by: Dan Ribbens <DanRibbens@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-05-01 19:17:47 +00:00

237 lines
6.4 KiB
TypeScript

'use client'
import type { KeyboardEventHandler } from 'react'
import { arrayMove } from '@dnd-kit/sortable'
import { getTranslation } from '@payloadcms/translations'
import React, { useEffect, useId } from 'react'
import Select, { type StylesConfig } from 'react-select'
import CreatableSelect from 'react-select/creatable'
import type { Option, ReactSelectAdapterProps } from './types.js'
export type { Option } from './types.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { DraggableSortable } from '../DraggableSortable/index.js'
import { ShimmerEffect } from '../ShimmerEffect/index.js'
import { ClearIndicator } from './ClearIndicator/index.js'
import { Control } from './Control/index.js'
import { DropdownIndicator } from './DropdownIndicator/index.js'
import './index.scss'
import { Input } from './Input/index.js'
import { generateMultiValueDraggableID, MultiValue } from './MultiValue/index.js'
import { MultiValueLabel } from './MultiValueLabel/index.js'
import { MultiValueRemove } from './MultiValueRemove/index.js'
import { SingleValue } from './SingleValue/index.js'
import { ValueContainer } from './ValueContainer/index.js'
const createOption = (label: string) => ({
label,
value: label,
})
const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
const { i18n, t } = useTranslation()
const [inputValue, setInputValue] = React.useState('') // for creatable select
const uuid = useId()
const [hasMounted, setHasMounted] = React.useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
const {
className,
components,
customProps,
disabled = false,
filterOption = undefined,
getOptionValue,
isClearable = true,
isCreatable,
isLoading,
isSearchable = true,
noOptionsMessage = () => t('general:noOptions'),
numberOnly = false,
onChange,
onMenuClose,
onMenuOpen,
options,
placeholder = t('general:selectValue'),
showError,
value,
} = props
const loadingMessage = () => t('general:loading') + '...'
const classes = [className, 'react-select', showError && 'react-select--error']
.filter(Boolean)
.join(' ')
const styles: StylesConfig<Option> = {
// Remove the default react-select z-index from the menu so that our custom
// z-index in the "payload-default" css layer can take effect, in such a way
// that end users can easily override it as with other styles.
menu: (rsStyles) => ({ ...rsStyles, zIndex: undefined }),
}
if (!hasMounted) {
return <ShimmerEffect height="calc(var(--base) * 2 + 2px)" />
}
if (!isCreatable) {
return (
<Select
captureMenuScroll
customProps={customProps}
isLoading={isLoading}
{...props}
className={classes}
classNamePrefix="rs"
components={{
ClearIndicator,
Control,
DropdownIndicator,
Input,
MultiValue,
MultiValueLabel,
MultiValueRemove,
SingleValue,
ValueContainer,
...components,
}}
filterOption={filterOption}
getOptionValue={getOptionValue}
instanceId={uuid}
isClearable={isClearable}
isDisabled={disabled}
isSearchable={isSearchable}
loadingMessage={loadingMessage}
menuPlacement="auto"
noOptionsMessage={noOptionsMessage}
onChange={onChange}
onMenuClose={onMenuClose}
onMenuOpen={onMenuOpen}
options={options}
placeholder={getTranslation(placeholder, i18n)}
styles={styles}
unstyled={true}
value={value}
/>
)
}
const handleKeyDown: KeyboardEventHandler = (event) => {
if (numberOnly === true) {
const acceptableKeys = [
'Tab',
'Escape',
'Backspace',
'Enter',
'ArrowRight',
'ArrowLeft',
'ArrowUp',
'ArrowDown',
]
const isNumber = !/\D/.test(event.key)
const isActionKey = acceptableKeys.includes(event.key)
if (!isNumber && !isActionKey) {
event.preventDefault()
return
}
}
if (!value || !inputValue || inputValue.trim() === '') {
return
}
if (filterOption && !filterOption(null, inputValue)) {
return
}
switch (event.key) {
case 'Enter':
case 'Tab':
onChange([...(value as Option[]), createOption(inputValue)])
setInputValue('')
event.preventDefault()
break
default:
break
}
}
return (
<CreatableSelect
captureMenuScroll
isLoading={isLoading}
{...props}
className={classes}
classNamePrefix="rs"
components={{
ClearIndicator,
Control,
DropdownIndicator,
Input,
MultiValue,
MultiValueLabel,
MultiValueRemove,
SingleValue,
ValueContainer,
...components,
}}
filterOption={filterOption}
inputValue={inputValue}
instanceId={uuid}
isClearable={isClearable}
isDisabled={disabled}
isSearchable={isSearchable}
loadingMessage={loadingMessage}
menuPlacement="auto"
noOptionsMessage={noOptionsMessage}
onChange={onChange}
onInputChange={(newValue) => setInputValue(newValue)}
onKeyDown={handleKeyDown}
onMenuClose={onMenuClose}
onMenuOpen={onMenuOpen}
options={options}
placeholder={getTranslation(placeholder, i18n)}
styles={styles}
unstyled={true}
value={value}
/>
)
}
const SortableSelect: React.FC<ReactSelectAdapterProps> = (props) => {
const { getOptionValue, onChange, value } = props
let draggableIDs: string[] = []
if (value) {
draggableIDs = (Array.isArray(value) ? value : [value]).map((optionValue) => {
return generateMultiValueDraggableID(optionValue, getOptionValue)
})
}
return (
<DraggableSortable
className="react-select-container"
ids={draggableIDs}
onDragEnd={({ moveFromIndex, moveToIndex }) => {
let sorted = value
if (value && Array.isArray(value)) {
sorted = arrayMove(value, moveFromIndex, moveToIndex)
}
onChange(sorted)
}}
>
<SelectAdapter {...props} />
</DraggableSortable>
)
}
export const ReactSelect: React.FC<ReactSelectAdapterProps> = (props) => {
const { isMulti, isSortable } = props
if (isMulti && isSortable) {
return <SortableSelect {...props} />
}
return <SelectAdapter {...props} />
}