diff --git a/components/doc/dropdown/accessibilitydoc.js b/components/doc/dropdown/accessibilitydoc.js index 35727b78e4..ddeffac1ca 100644 --- a/components/doc/dropdown/accessibilitydoc.js +++ b/components/doc/dropdown/accessibilitydoc.js @@ -5,9 +5,9 @@ export function AccessibilityDoc() { const code = { basic: ` Options - + - + ` }; diff --git a/components/doc/dropdown/floatlabeldoc.js b/components/doc/dropdown/floatlabeldoc.js index 7477afe488..23a7f78dfc 100644 --- a/components/doc/dropdown/floatlabeldoc.js +++ b/components/doc/dropdown/floatlabeldoc.js @@ -39,8 +39,8 @@ export default function FloatLabelDemo() { return (
- setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> - + setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> +
) @@ -69,8 +69,8 @@ export default function FloatLabelDemo() { return (
- setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> - + setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> +
) @@ -85,8 +85,10 @@ export default function FloatLabelDemo() {
- setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> - + setSelectedCity(e.value)} options={cities} optionLabel="name" showClear className="w-full" /> +
diff --git a/components/doc/dropdown/labeldoc.js b/components/doc/dropdown/labeldoc.js new file mode 100644 index 0000000000..d31da71ee1 --- /dev/null +++ b/components/doc/dropdown/labeldoc.js @@ -0,0 +1,97 @@ +import { DocSectionCode } from '@/components/doc/common/docsectioncode'; +import { DocSectionText } from '@/components/doc/common/docsectiontext'; +import { Dropdown } from '@/components/lib/dropdown/Dropdown'; +import { useState } from 'react'; + +export function LabelDoc(props) { + const [selectedCity, setSelectedCity] = useState(null); + const cities = [ + { name: 'New York', code: 'NY' }, + { name: 'Rome', code: 'RM' }, + { name: 'London', code: 'LDN' }, + { name: 'Istanbul', code: 'IST' }, + { name: 'Paris', code: 'PRS' } + ]; + + const code = { + basic: ` +
+ + setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> +
+ `, + javascript: ` +import React, { useState } from "react"; +import { Dropdown } from 'primereact/dropdown'; + +export default function FloatLabelDemo() { + const [selectedCity, setSelectedCity] = useState(null); + const cities = [ + { name: 'New York', code: 'NY' }, + { name: 'Rome', code: 'RM' }, + { name: 'London', code: 'LDN' }, + { name: 'Istanbul', code: 'IST' }, + { name: 'Paris', code: 'PRS' } + ]; + + return ( +
+
+ + setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> +
+
+ ) +} + `, + typescript: ` +import React, { useState } from "react"; +import { Dropdown, DropdownChangeEvent } from 'primereact/dropdown'; +import { div } from 'primereact/floatlabel'; + +interface City { + name: string; + code: string; +} + +export default function FloatLabelDemo() { + const [selectedCity, setSelectedCity] = useState(null); + const cities: City[] = [ + { name: 'New York', code: 'NY' }, + { name: 'Rome', code: 'RM' }, + { name: 'London', code: 'LDN' }, + { name: 'Istanbul', code: 'IST' }, + { name: 'Paris', code: 'PRS' } + ]; + + return ( +
+
+ + setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> +
+
+ ) +} + ` + }; + + return ( + <> + +

+ Dropdown associated with a label using the aria-labelledby property and the label element's id attribute. +

+
+
+
+ + setSelectedCity(e.value)} options={cities} optionLabel="name" className="w-full" /> +
+
+ + + ); +} diff --git a/components/lib/dropdown/Dropdown.js b/components/lib/dropdown/Dropdown.js index f764cb5f13..310ba32e93 100644 --- a/components/lib/dropdown/Dropdown.js +++ b/components/lib/dropdown/Dropdown.js @@ -27,6 +27,7 @@ export const Dropdown = React.memo( const firstHiddenFocusableElementOnOverlay = React.useRef(null); const lastHiddenFocusableElementOnOverlay = React.useRef(null); const inputRef = React.useRef(props.inputRef); + const labelRef = React.useRef(props.labelRef); const focusInputRef = React.useRef(props.focusInputRef); const virtualScrollerRef = React.useRef(null); const searchTimeout = React.useRef(null); @@ -43,6 +44,10 @@ export const Dropdown = React.memo( overlayVisible: overlayVisibleState } }); + const ariaLabel = props.ariaLabel || props['aria-label']; + const ariaLabelledBy = props.ariaLabelledBy || props['aria-labelledby']; + const uniqueId = React.useId(); + const panelId = props.pt?.list?.id || `p_panel_${uniqueId}`; useHandleStyle(DropdownBase.css.styles, isUnstyled, { name: 'dropdown' }); @@ -127,7 +132,7 @@ export const Dropdown = React.memo( if (isClearClicked(event) || event.target.tagName === 'INPUT') { return; } else if (!overlayRef.current || !(overlayRef.current && overlayRef.current.contains(event.target))) { - DomHandler.focus(focusInputRef.current); + focus(); overlayVisibleState ? hide() : show(); } @@ -135,6 +140,10 @@ export const Dropdown = React.memo( clickedRef.current = true; }; + const onLabelFocus = () => { + focus(); + }; + const onInputFocus = (event) => { if (props.showOnFocus && !overlayVisibleState) { show(); @@ -148,25 +157,24 @@ export const Dropdown = React.memo( setFocusedState(false); if (props.onBlur) { - setTimeout(() => { - const currentValue = inputRef.current ? inputRef.current.value : undefined; + const optionValue = getOptionValue(selectedOption); + const inputValue = getInputValue(selectedOption); - props.onBlur({ - originalEvent: event.originalEvent, - value: currentValue, - stopPropagation: () => { - event.originalEvent.stopPropagation(); - }, - preventDefault: () => { - event.originalEvent.preventDefault(); - }, - target: { - name: props.name, - id: props.id, - value: currentValue - } - }); - }, 200); + props.onBlur({ + originalEvent: event.originalEvent, + value: optionValue, + stopPropagation: () => { + event.originalEvent.stopPropagation(); + }, + preventDefault: () => { + event.originalEvent.preventDefault(); + }, + target: { + name: props.name, + id: props.inputId, + value: inputValue + } + }); } }; @@ -491,6 +499,7 @@ export const Dropdown = React.memo( } hide(); + focus(); } event.preventDefault(); @@ -624,7 +633,7 @@ export const Dropdown = React.memo( }, target: { name: props.name, - id: props.id, + id: props.inputId, value: event.target.value } }); @@ -642,7 +651,7 @@ export const Dropdown = React.memo( if (!option.disabled) { selectItem(event); - DomHandler.focus(focusInputRef.current); + focus(); } hide(); @@ -674,8 +683,8 @@ export const Dropdown = React.memo( const clear = (event) => { if (props.onChange) { props.onChange({ - originalEvent: event, - value: undefined, + originalEvent: event.originalEvent, + value: null, stopPropagation: () => { event?.stopPropagation(); }, @@ -684,7 +693,7 @@ export const Dropdown = React.memo( }, target: { name: props.name, - id: props.id, + id: props.inputId, value: undefined } }); @@ -702,8 +711,9 @@ export const Dropdown = React.memo( if (selectedOption !== event.option) { updateEditableLabel(event.option); setFocusedOptionIndex(-1); - const optionValue = getOptionValue(event.option); + const optionValue = getOptionValue(event.option); + const inputValue = getInputValue(event.option); const selectedOptionIndex = findOptionIndexInList(event.option, visibleOptions); if (props.onChange) { @@ -718,8 +728,8 @@ export const Dropdown = React.memo( }, target: { name: props.name, - id: props.id, - value: optionValue + id: props.inputId, + value: inputValue } }); } @@ -772,9 +782,20 @@ export const Dropdown = React.memo( clickedRef.current = false; }; + const focus = React.useCallback( + (autoFocus) => { + if (props.editable) { + DomHandler.focus(inputRef.current, autoFocus); + } else { + DomHandler.focus(focusInputRef.current, autoFocus); + } + }, + [props.editable] + ); + const onFocus = () => { if (props.editable && !overlayVisibleState && clickedRef.current === false) { - DomHandler.focus(inputRef.current); + focus(); } }; @@ -807,7 +828,7 @@ export const Dropdown = React.memo( }; const alignOverlay = () => { - DomHandler.alignOverlay(overlayRef.current, inputRef.current.parentElement, props.appendTo || (context && context.appendTo) || PrimeReact.appendTo); + DomHandler.alignOverlay(overlayRef.current, elementRef.current, props.appendTo || (context && context.appendTo) || PrimeReact.appendTo); }; const scrollInView = () => { @@ -840,7 +861,7 @@ export const Dropdown = React.memo( return `${option}`; } - const optionLabel = props.optionLabel ? ObjectUtils.resolveFieldData(option, props.optionLabel) : option['label']; + const optionLabel = props.optionLabel ? ObjectUtils.resolveFieldData(option, props.optionLabel) : option.label; return `${optionLabel}`; }; @@ -850,7 +871,7 @@ export const Dropdown = React.memo( return option; } - const optionValue = props.optionValue ? ObjectUtils.resolveFieldData(option, props.optionValue) : option ? option['value'] : ObjectUtils.resolveFieldData(option, 'value'); + const optionValue = props.optionValue ? ObjectUtils.resolveFieldData(option, props.optionValue) : option ? option.value : ObjectUtils.resolveFieldData(option, 'value'); return props.optionValue || ObjectUtils.isNotEmpty(optionValue) ? optionValue : option; }; @@ -859,6 +880,17 @@ export const Dropdown = React.memo( return props.dataKey ? ObjectUtils.resolveFieldData(option, props.dataKey) : getOptionLabel(option); }; + const getInputValue = (option) => { + if (ObjectUtils.isNotEmpty(selectedOption)) { + const optionValue = getOptionValue(option); + const optionLabel = getOptionLabel(option); + + return typeof optionValue === 'object' ? optionLabel : optionValue; + } + + return ''; + }; + const isOptionGroup = (option) => { return props.optionGroupLabel && option.group; }; @@ -908,9 +940,10 @@ export const Dropdown = React.memo( show, hide, clear, - focus: () => DomHandler.focus(focusInputRef.current), + focus, getElement: () => elementRef.current, getOverlay: () => overlayRef.current, + getLabel: () => labelRef.current, getInput: () => inputRef.current, getFocusInput: () => focusInputRef.current, getVirtualScroller: () => virtualScrollerRef.current @@ -919,11 +952,31 @@ export const Dropdown = React.memo( React.useEffect(() => { ObjectUtils.combinedRefs(inputRef, props.inputRef); ObjectUtils.combinedRefs(focusInputRef, props.focusInputRef); - }, [inputRef, props.inputRef, focusInputRef, props.focusInputRef]); + ObjectUtils.combinedRefs(labelRef, props.labelRef); + }, [inputRef, props.inputRef, focusInputRef, props.focusInputRef, labelRef, props.labelRef]); + + React.useEffect(() => { + const labelElement = ariaLabelledBy ? document.getElementById(ariaLabelledBy) : null; + + const handleClick = () => { + focus(); + + if (!overlayVisibleState) { + show(); + } + }; + + labelElement?.addEventListener('click', handleClick); + + return () => { + labelElement?.removeEventListener('click', handleClick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- show and focus are not required to be in the dependency array + }, [ariaLabelledBy, overlayVisibleState]); useMountEffect(() => { if (props.autoFocus) { - DomHandler.focus(focusInputRef.current, props.autoFocus); + focus(true); } alignOverlay(); @@ -951,67 +1004,17 @@ export const Dropdown = React.memo( } updateInputField(); - - if (inputRef.current) { - inputRef.current.selectedIndex = 1; - } }); useUnmountEffect(() => { ZIndexUtils.clear(overlayRef.current); }); - const createHiddenSelect = () => { - let option = { value: '', label: props.placeholder }; - - if (selectedOption) { - const optionValue = getOptionValue(selectedOption); - - option = { - value: typeof optionValue === 'object' ? props.options.findIndex((o) => o === optionValue) : optionValue, - label: getOptionLabel(selectedOption) - }; - } - - const hiddenSelectedMessageProps = mergeProps( - { - className: 'p-hidden-accessible p-dropdown-hidden-select' - }, - ptm('hiddenSelectedMessage') - ); - - const selectProps = mergeProps( - { - ref: inputRef, - required: props.required, - defaultValue: option.value, - name: props.name, - tabIndex: -1 - }, - ptm('select') - ); - - const optionProps = mergeProps( - { - value: option.value - }, - ptm('option') - ); - - return ( -
- -
- ); - }; - const createKeyboardHelper = () => { - let value = ObjectUtils.isNotEmpty(selectedOption) ? getOptionLabel(selectedOption) : null; + const value = getInputValue(selectedOption); if (props.editable) { - value = value || props.value || ''; + return null; } const hiddenSelectedMessageProps = mergeProps( @@ -1021,27 +1024,9 @@ export const Dropdown = React.memo( ptm('hiddenSelectedMessage') ); - const inputProps = mergeProps( - { - ref: focusInputRef, - id: props.inputId, - defaultValue: value, - type: 'text', - readOnly: true, - 'aria-haspopup': 'listbox', - onFocus: onInputFocus, - onBlur: onInputBlur, - onKeyDown: onInputKeyDown, - disabled: props.disabled, - tabIndex: !props.disabled ? props.tabIndex || 0 : -1, - ...ariaProps - }, - ptm('input') - ); - return (
- +
); }; @@ -1049,6 +1034,20 @@ export const Dropdown = React.memo( const createLabel = () => { const label = ObjectUtils.isNotEmpty(selectedOption) ? getOptionLabel(selectedOption) : null; + const sharedAccessibilityProps = { + 'aria-activedescendant': focusedState ? `dropdownItem_${focusedOptionIndex}` : undefined, + 'aria-controls': panelId, + 'aria-expanded': overlayVisibleState, + 'aria-haspopup': 'listbox', + 'aria-invalid': props.invalid, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + id: props.inputId, + role: 'combobox', + tabIndex: !props.disabled ? props.tabIndex || 0 : -1, + ...ariaProps + }; + if (props.editable) { const value = label || props.value || ''; const inputProps = mergeProps( @@ -1057,16 +1056,16 @@ export const Dropdown = React.memo( type: 'text', defaultValue: value, className: cx('input', { label }), + name: props.name, disabled: props.disabled, + required: props.required, placeholder: props.placeholder, maxLength: props.maxLength, onInput: onEditableInputChange, onFocus: onEditableInputFocus, onKeyDown: onInputKeyDown, onBlur: onInputBlur, - tabIndex: !props.disabled ? props.tabIndex || 0 : -1, - 'aria-haspopup': 'listbox', - ...ariaProps + ...sharedAccessibilityProps }, ptm('input') ); @@ -1075,16 +1074,19 @@ export const Dropdown = React.memo( } const content = props.valueTemplate ? ObjectUtils.getJSXElement(props.valueTemplate, selectedOption, props) : label || props.placeholder || props.emptyMessage || <> ; - const inputProps = mergeProps( + const labelProps = mergeProps( { - ref: inputRef, + ref: labelRef, className: cx('input', { label }), - tabIndex: '-1' + 'aria-disabled': props.disabled, + 'aria-required': props.required, + onFocus: onLabelFocus, + ...sharedAccessibilityProps }, - ptm('input') + ptm('label') ); - return {content}; + return {content}; }; const onClearIconKeyDown = (event) => { @@ -1095,7 +1097,7 @@ export const Dropdown = React.memo( }; const createClearIcon = () => { - if (props.value != null && props.showClear && !props.disabled && !ObjectUtils.isEmpty(props.options)) { + if (props.value !== null && props.showClear && !props.disabled && !ObjectUtils.isEmpty(props.options)) { const clearIconProps = mergeProps( { className: cx('clearIcon'), @@ -1124,14 +1126,13 @@ export const Dropdown = React.memo( ); const icon = props.loadingIcon || ; const loadingIcon = IconUtils.getJSXIcon(icon, { ...loadingIconProps }, { props }); - const ariaLabel = props.placeholder || props.ariaLabel; const loadingButtonProps = mergeProps( { className: cx('trigger'), role: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': overlayVisibleState, - 'aria-label': ariaLabel + 'aria-label': props.placeholder || ariaLabel }, ptm('trigger') ); @@ -1150,14 +1151,13 @@ export const Dropdown = React.memo( const icon = !overlayVisibleState ? props.dropdownIcon || : props.collapseIcon || ; const dropdownIcon = IconUtils.getJSXIcon(icon, { ...dropdownIconProps }, { props }); - const ariaLabel = props.placeholder || props.ariaLabel; const triggerProps = mergeProps( { className: cx('trigger'), role: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': overlayVisibleState, - 'aria-label': ariaLabel + 'aria-label': props.placeholder || ariaLabel }, ptm('trigger') ); @@ -1171,7 +1171,6 @@ export const Dropdown = React.memo( const hasTooltip = ObjectUtils.isNotEmpty(props.tooltip); const otherProps = DropdownBase.getOtherProps(props); const ariaProps = ObjectUtils.reduceKeys(otherProps, DomHandler.ARIA_PROPS); - const hiddenSelect = createHiddenSelect(); const keyboardHelper = createKeyboardHelper(); const labelElement = createLabel(); const dropdownIcon = props.loading ? createLoadingIcon() : createDropdownIcon(); @@ -1187,8 +1186,7 @@ export const Dropdown = React.memo( onContextMenu: props.onContextMenu, onFocus: onFocus, 'data-p-disabled': props.disabled, - 'data-p-focus': focusedState, - 'aria-activedescendant': focusedState ? `dropdownItem_${focusedOptionIndex}` : undefined + 'data-p-focus': focusedState }, otherProps, ptm('root') @@ -1224,7 +1222,6 @@ export const Dropdown = React.memo( <>
{keyboardHelper} - {hiddenSelect} {labelElement} {clearIcon} {dropdownIcon} @@ -1245,6 +1242,7 @@ export const Dropdown = React.memo( getOptionRenderKey={getOptionRenderKey} getSelectedOptionIndex={getSelectedOptionIndex} hasFilter={hasFilter} + id={panelId} in={overlayVisibleState} isOptionDisabled={isOptionDisabled} isSelected={isSelected} diff --git a/components/lib/dropdown/DropdownBase.js b/components/lib/dropdown/DropdownBase.js index e30c9c1f8f..c4b3bfff06 100644 --- a/components/lib/dropdown/DropdownBase.js +++ b/components/lib/dropdown/DropdownBase.js @@ -161,8 +161,8 @@ export const DropdownBase = ComponentBase.extend({ __TYPE: 'Dropdown', __parentMetadata: null, appendTo: null, - ariaLabel: null, - ariaLabelledBy: null, + ariaLabel: undefined, + ariaLabelledBy: undefined, autoFocus: false, autoOptionFocus: false, checkmark: false, @@ -171,7 +171,7 @@ export const DropdownBase = ComponentBase.extend({ clearIcon: null, collapseIcon: null, dataKey: null, - disabled: false, + disabled: undefined, dropdownIcon: null, editable: false, emptyFilterMessage: null, @@ -190,14 +190,14 @@ export const DropdownBase = ComponentBase.extend({ focusOnHover: true, highlightOnSelect: true, id: null, - inputId: null, + inputId: undefined, inputRef: null, - invalid: false, + invalid: undefined, itemTemplate: null, loading: false, loadingIcon: null, maxLength: null, - name: null, + name: undefined, onBlur: null, onChange: null, onContextMenu: null, @@ -217,7 +217,7 @@ export const DropdownBase = ComponentBase.extend({ panelFooterTemplate: null, panelStyle: null, placeholder: null, - required: false, + required: undefined, resetFilterOnHide: false, scrollHeight: '200px', selectOnFocus: false, diff --git a/components/lib/dropdown/DropdownPanel.js b/components/lib/dropdown/DropdownPanel.js index d506bf3a65..c0674447da 100644 --- a/components/lib/dropdown/DropdownPanel.js +++ b/components/lib/dropdown/DropdownPanel.js @@ -261,7 +261,8 @@ export const DropdownPanel = React.memo( ref: options.contentRef, style: options.style, className: classNames(options.className, cx('list', { virtualScrollerProps: props.virtualScrollerOptions })), - role: 'listbox' + role: 'listbox', + id: props.id }, getPTOptions('list') ); @@ -286,7 +287,8 @@ export const DropdownPanel = React.memo( const listProps = mergeProps( { className: cx('list'), - role: 'listbox' + role: 'listbox', + id: props.id }, getPTOptions('list') ); diff --git a/components/lib/dropdown/dropdown.d.ts b/components/lib/dropdown/dropdown.d.ts index 8750f5af5f..cf88a88808 100644 --- a/components/lib/dropdown/dropdown.d.ts +++ b/components/lib/dropdown/dropdown.d.ts @@ -40,6 +40,10 @@ export interface DropdownPassThroughOptions { * Uses to pass attributes to the root's DOM element. */ root?: DropdownPassThroughType>; + /** + * Uses to pass attributes to the label's DOM element. + */ + label?: DropdownPassThroughType>; /** * Uses to pass attributes to the input's DOM element. */ @@ -361,7 +365,7 @@ export interface DropdownProps extends Omit | undefined; /** - * Unique identifier of the element. + * Unique identifier of the wrapper element. */ id?: string | undefined; /** @@ -380,17 +384,21 @@ export interface DropdownProps extends Omit | undefined; + inputRef?: React.Ref | undefined; /** * The template of items. */ itemTemplate?: React.ReactNode | ((option: any) => React.ReactNode) | undefined; + /** + * Reference of the label element. + */ + labelRef?: React.Ref | undefined; /** * Displays a loader to indicate data load is in progress. * @defaultValue false @@ -616,6 +624,11 @@ export declare class Dropdown extends React.Component { * @return {HTMLDivElement} Container element */ public getElement(): HTMLDivElement; + /** + * Used to get label element. + * @return {HTMLInputElement} Input element + */ + public getLabel(): HTMLSpanElement; /** * Used to get input element. * @return {HTMLInputElement} Input element diff --git a/pages/dropdown/index.js b/pages/dropdown/index.js index c310ac725e..0a858ab145 100644 --- a/pages/dropdown/index.js +++ b/pages/dropdown/index.js @@ -12,6 +12,7 @@ import { FloatLabelDoc } from '@/components/doc/dropdown/floatlabeldoc'; import { GroupDoc } from '@/components/doc/dropdown/groupdoc'; import { ImportDoc } from '@/components/doc/dropdown/importdoc'; import { InvalidDoc } from '@/components/doc/dropdown/invaliddoc'; +import { LabelDoc } from '@/components/doc/dropdown/labeldoc'; import { LazyVirtualScrollDoc } from '@/components/doc/dropdown/lazyvirtualscrolldoc'; import { LoadingDoc } from '@/components/doc/dropdown/loadingdoc'; import { Wireframe } from '@/components/doc/dropdown/pt/wireframe'; @@ -77,6 +78,11 @@ const DropdownDemo = () => { label: 'Lazy Virtual Scroll', component: LazyVirtualScrollDoc }, + { + id: 'label', + label: 'Label', + component: LabelDoc + }, { id: 'floatlabel', label: 'Float Label',