diff --git a/components/doc/common/apidoc/index.json b/components/doc/common/apidoc/index.json index a564b6ef5d..a396f3a161 100644 --- a/components/doc/common/apidoc/index.json +++ b/components/doc/common/apidoc/index.json @@ -32382,6 +32382,22 @@ "props": { "description": "Defines valid properties in Menu component. In addition to these, all properties of HTMLDivElement can be used in this component.", "values": [ + { + "name": "ariaLabel", + "optional": true, + "readonly": false, + "type": "string", + "default": "", + "description": "Used to define a string that labels the component." + }, + { + "name": "ariaLabelledBy", + "optional": true, + "readonly": false, + "type": "string", + "default": "", + "description": "Establishes relationships between the component and label(s) where its value should be one or more element IDs." + }, { "name": "appendTo", "optional": true, @@ -32430,6 +32446,14 @@ "default": "", "description": "An array of menuitems." }, + { + "name": "tabIndex", + "optional": true, + "readonly": false, + "type": "number", + "default": "", + "description": "Index of the element in tabbing order." + }, { "name": "popup", "optional": true, @@ -32508,6 +32532,32 @@ ], "returnType": "void", "description": "Callback to invoke when a popup menu is shown." + }, + { + "name": "onFocus", + "parameters": [ + { + "name": "event", + "optional": false, + "type": "SyntheticEvent", + "description": "Browser event." + } + ], + "returnType": "void", + "description": "Callback to invoke when menu receives focus." + }, + { + "name": "onBlur", + "parameters": [ + { + "name": "event", + "optional": false, + "type": "SyntheticEvent", + "description": "Browser event." + } + ], + "returnType": "void", + "description": "Callback to invoke when menu loses focus." } ] } @@ -38275,22 +38325,6 @@ "default": "", "description": "A map of keys to represent the expansion state in controlled mode." }, - { - "name": "onShow", - "optional": true, - "readonly": false, - "type": "Function", - "default": "", - "description": "Callback to invoke when a tab gets expanded." - }, - { - "name": "onHide", - "optional": true, - "readonly": false, - "type": "Function", - "description": "Callback to invoke when a tab gets collapsed.", - "default": "" - }, { "name": "pt", "optional": true, @@ -38335,7 +38369,50 @@ }, "callbacks": { "description": "Defines callbacks that determine the behavior of the component based on a given condition or report the actions that the component takes.", - "values": [] + "values": [ + { + "name": "onClose", + "parameters": [ + { + "name": "originalEvent", + "optional": false, + "readonly": false, + "type": "SyntheticEvent", + "description": "Browser event." + }, + { + "name": "item", + "optional": false, + "readonly": false, + "type": "MenuItem", + "description": "Collapsed item." + } + ], + "returnType": "void", + "description": "Callback to invoke when a tab gets collapsed." + }, + { + "name": "onOpen", + "parameters": [ + { + "name": "originalEvent", + "optional": false, + "readonly": false, + "type": "SyntheticEvent", + "description": "Browser event." + }, + { + "name": "item", + "optional": false, + "readonly": false, + "type": "MenuItem", + "description": "Expanded item." + } + ], + "returnType": "void", + "description": "Callback to invoke when a tab gets expanded." + } + ] } } }, diff --git a/components/lib/menu/Menu.js b/components/lib/menu/Menu.js index 8a81d6d5e4..bb623907ff 100644 --- a/components/lib/menu/Menu.js +++ b/components/lib/menu/Menu.js @@ -15,6 +15,10 @@ export const Menu = React.memo( const props = MenuBase.getProps(inProps, context); const [idState, setIdState] = React.useState(props.id); const [visibleState, setVisibleState] = React.useState(!props.popup); + const [focusedOptionIndex, setFocusedOptionIndex] = React.useState(-1); + const [selectedOptionIndex, setSelectedOptionIndex] = React.useState(-1); + const [focused, setFocused] = React.useState(false); + const { ptm, cx, sx, isUnstyled } = MenuBase.setMetaData({ props, state: { @@ -25,6 +29,7 @@ export const Menu = React.memo( useHandleStyle(MenuBase.css.styles, isUnstyled, { name: 'menu' }); const menuRef = React.useRef(null); + const listRef = React.useRef(null); const targetRef = React.useRef(null); useOnEscapeKey(targetRef, props.popup && props.closeOnEscape, (event) => { @@ -35,7 +40,10 @@ export const Menu = React.memo( target: targetRef, overlay: menuRef, listener: (event, { valid }) => { - valid && hide(event); + if (valid) { + hide(event); + setFocusedOptionIndex(-1); + } }, when: visibleState }); @@ -49,7 +57,7 @@ export const Menu = React.memo( } }; - const onItemClick = (event, item) => { + const onItemClick = (event, item, key) => { if (item.disabled) { event.preventDefault(); @@ -70,26 +78,68 @@ export const Menu = React.memo( if (props.popup) { hide(event); } + + if (!props.popup && focusedOptionIndex !== key) { + setFocusedOptionIndex(key); + } + + event.preventDefault(); + event.stopPropagation(); + }; + + const onListFocus = (event) => { + setFocused(true); + + if (!props.popup) { + if (selectedOptionIndex !== -1) { + changeFocusedOptionIndex(selectedOptionIndex); + setSelectedOptionIndex(-1); + } else changeFocusedOptionIndex(0); + } + + props.onFocus && props.onFocus(event); + }; + + const onListBlur = (event) => { + setFocused(false); + setFocusedOptionIndex(-1); + props.onBlur && props.onBlur(event); }; - const onItemKeyDown = (event, item) => { - const listItem = event.currentTarget.parentElement; + const onListKeyDown = (event) => { + switch (event.code) { + case 'ArrowDown': + onArrowDownKey(event); + break; + + case 'ArrowUp': + onArrowUpKey(event); + break; + + case 'Home': + onHomeKey(event); + break; - switch (event.which) { - //down - case 40: - const nextItem = findNextItem(listItem); + case 'End': + onEndKey(event); + break; - nextItem && nextItem.children[0].focus(); - event.preventDefault(); + case 'Enter': + onEnterKey(event); break; - //up - case 38: - const prevItem = findPrevItem(listItem); + case 'Space': + onSpaceKey(event); + break; - prevItem && prevItem.children[0].focus(); - event.preventDefault(); + case 'Escape': + if (popup) { + DomHandler.focus(targetRef.current); + hide(event); + } + + case 'Tab': + props.popup && visibleState && hide(); break; default: @@ -97,16 +147,73 @@ export const Menu = React.memo( } }; - const findNextItem = (item) => { - const nextItem = item.nextElementSibling; + const onArrowDownKey = (event) => { + const optionIndex = findNextOptionIndex(focusedOptionIndex); + + changeFocusedOptionIndex(optionIndex); + event.preventDefault(); + }; + + const onArrowUpKey = (event) => { + if (event.altKey && popup) { + DomHandler.focus(targetRef.current); + hide(); + event.preventDefault(); + } else { + const optionIndex = findPrevOptionIndex(focusedOptionIndex); - return nextItem ? (DomHandler.getAttribute(nextItem, '[data-p-disabled="true"]') || !DomHandler.getAttribute(nextItem, '[data-pc-section="menuitem"]') ? findNextItem(nextItem) : nextItem) : null; + changeFocusedOptionIndex(optionIndex); + event.preventDefault(); + } }; - const findPrevItem = (item) => { - const prevItem = item.previousElementSibling; + const onHomeKey = (event) => { + changeFocusedOptionIndex(0); + event.preventDefault(); + }; - return prevItem ? (DomHandler.getAttribute(prevItem, '[data-p-disabled="true"]') || !DomHandler.getAttribute(prevItem, '[data-pc-section="menuitem"]') ? findPrevItem(prevItem) : prevItem) : null; + const onEndKey = (event) => { + changeFocusedOptionIndex(DomHandler.find(menuRef.current, 'li[data-pc-section="menuitem"][data-p-disabled="false"]').length - 1); + event.preventDefault(); + }; + + const onEnterKey = (event) => { + const element = DomHandler.findSingle(menuRef.current, `li[id="${`${focusedOptionIndex}`}"]`); + const anchorElement = element && DomHandler.findSingle(element, 'a[data-pc-section="action"]'); + + popup && DomHandler.focus(targetRef.current); + anchorElement ? anchorElement.click() : element && element.click(); + + event.preventDefault(); + }; + + const onSpaceKey = (event) => { + onEnterKey(event); + }; + + const findNextOptionIndex = (index) => { + const links = DomHandler.find(menuRef.current, 'li[data-pc-section="menuitem"][data-p-disabled="false"]'); + const matchedOptionIndex = [...links].findIndex((link) => link.id === index); + + return matchedOptionIndex > -1 ? matchedOptionIndex + 1 : 0; + }; + + const findPrevOptionIndex = (index) => { + const links = DomHandler.find(menuRef.current, 'li[data-pc-section="menuitem"][data-p-disabled="false"]'); + const matchedOptionIndex = [...links].findIndex((link) => link.id === index); + + return matchedOptionIndex > -1 ? matchedOptionIndex - 1 : 0; + }; + + const changeFocusedOptionIndex = (index) => { + const links = DomHandler.find(menuRef.current, 'li[data-pc-section="menuitem"][data-p-disabled="false"]'); + let order = index >= links.length ? links.length - 1 : index < 0 ? 0 : index; + + order > -1 && setFocusedOptionIndex(links[order].getAttribute('id')); + }; + + const focusedOptionId = () => { + return focusedOptionIndex !== -1 ? focusedOptionIndex : null; }; const toggle = (event) => { @@ -131,6 +238,11 @@ export const Menu = React.memo( DomHandler.addStyles(menuRef.current, { position: 'absolute', top: '0', left: '0' }); ZIndexUtils.set('menu', menuRef.current, (context && context.autoZIndex) || PrimeReact.autoZIndex, props.baseZIndex || (context && context.zIndex['menu']) || PrimeReact.zIndex['menu']); DomHandler.absolutePosition(menuRef.current, targetRef.current, props.popupAlignment); + + if (props.popup) { + DomHandler.focus(listRef.current); + changeFocusedOptionIndex(0); + } }; const onEntered = () => { @@ -167,12 +279,12 @@ export const Menu = React.memo( const createSubmenu = (submenu, index) => { const key = idState + '_sub_' + index; - const items = submenu.items.map(createMenuItem); + const items = submenu.items.map((item, index) => createMenuItem(item, index, key)); const submenuHeaderProps = mergeProps( { id: key, key, - role: 'presentation', + role: 'none', className: classNames(submenu.className, cx('submenuHeader', { submenu })), style: sx('submenuHeader', { submenu }), 'data-p-disabled': submenu.disabled @@ -203,7 +315,7 @@ export const Menu = React.memo( return
  • ; }; - const createMenuItem = (item, index) => { + const createMenuItem = (item, index, parentId = null) => { if (item.visible === false) { return null; } @@ -224,17 +336,24 @@ export const Menu = React.memo( ptm('label') ); const label = item.label && {item.label}; - const tabIndex = item.disabled ? null : 0; - const key = item.id || idState + '_' + index; + const key = item.id || (parentId || idState) + '_' + index; + const contentProps = mergeProps( + { + onClick: (event) => onItemClick(event, item, key), + className: cx('content') + }, + ptm('content') + ); + const actionProps = mergeProps( { href: item.url || '#', className: cx('action', { item }), - role: 'menuitem', + onFocus: (event) => event.stopPropagation(), target: item.target, - onClick: (event) => onItemClick(event, item), - onKeyDown: (event) => onItemKeyDown(event, item), - tabIndex: tabIndex, + tabIndex: '-1', + 'aria-label': item.label, + 'aria-hidden': true, 'aria-disabled': item.disabled, 'data-p-disabled': item.disabled }, @@ -242,18 +361,19 @@ export const Menu = React.memo( ); let content = ( - - {icon} - {label} - +
    + + {icon} + {label} + +
    ); if (item.template) { const defaultContentOptions = { - onClick: (event) => onItemClick(event, item), - onKeyDown: (event) => onItemKeyDown(event, item), + onClick: (event) => onItemClick(event, item, key), className: linkClassName, - tabIndex, + tabIndex: '-1', labelClassName: 'p-menuitem-text', iconClassName, element: content, @@ -267,9 +387,12 @@ export const Menu = React.memo( { id: key, key, - className: classNames(item.className, cx('menuitem')), + className: classNames(item.className, cx('menuitem', { focused: focusedOptionIndex === key })), style: sx('menuitem', { item }), - role: 'none', + role: 'menuitem', + 'aria-label': item.label, + 'aria-disabled': item.disabled, + 'data-p-focused': focusedOptionId() === key, 'data-p-disabled': item.disabled || false }, ptm('menuitem') @@ -301,8 +424,17 @@ export const Menu = React.memo( const menuProps = mergeProps( { + ref: listRef, className: cx('menu'), - role: 'menu' + id: idState + '_list', + tabIndex: props.tabIndex || '0', + role: 'menu', + 'aria-label': props.ariaLabel, + 'aria-labelledby': props.ariaLabelledBy, + 'aria-activedescendant': focused ? focusedOptionId() : undefined, + onFocus: onListFocus, + onKeyDown: onListKeyDown, + onBlur: onListBlur }, ptm('menu') ); diff --git a/components/lib/menu/MenuBase.js b/components/lib/menu/MenuBase.js index 8af6e17724..889d58612c 100644 --- a/components/lib/menu/MenuBase.js +++ b/components/lib/menu/MenuBase.js @@ -10,13 +10,13 @@ const styles = ` top: -9999px; left: -9999px; } - + .p-menu ul { margin: 0; padding: 0; list-style: none; } - + .p-menu .p-menuitem-link { cursor: pointer; display: flex; @@ -25,7 +25,7 @@ const styles = ` overflow: hidden; position: relative; } - + .p-menu .p-menuitem-text { line-height: 1; } @@ -40,8 +40,9 @@ const classes = { 'p-ripple-disabled': (context && context.ripple === false) || PrimeReact.ripple === false }), menu: 'p-menu-list p-reset', + content: 'p-menuitem-content', action: ({ item }) => classNames('p-menuitem-link', { 'p-disabled': item.disabled }), - menuitem: 'p-menuitem', + menuitem: ({ focused }) => classNames('p-menuitem', { 'p-focus': focused }), submenuHeader: ({ submenu }) => classNames('p-submenu-header', { 'p-disabled': submenu.disabled @@ -61,6 +62,9 @@ export const MenuBase = ComponentBase.extend({ defaultProps: { __TYPE: 'Menu', id: null, + ariaLabel: null, + ariaLabelledBy: null, + tabIndex: 0, model: null, popup: false, popupAlignment: 'left', @@ -69,6 +73,8 @@ export const MenuBase = ComponentBase.extend({ autoZIndex: true, baseZIndex: 0, appendTo: null, + onFocus: null, + onBlur: null, transitionOptions: null, onShow: null, onHide: null, diff --git a/components/lib/menu/menu.d.ts b/components/lib/menu/menu.d.ts index eb5cd99a0d..39fcb7d4d0 100644 --- a/components/lib/menu/menu.d.ts +++ b/components/lib/menu/menu.d.ts @@ -124,6 +124,10 @@ export interface MenuProps extends Omit