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