From 930014e7db74ede249ebe07cfc2aa7ea5731f406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20FINDIKLI?= Date: Sun, 24 Dec 2023 12:39:19 +0300 Subject: [PATCH] #5639 for Menubar --- components/doc/common/apidoc/index.json | 45 +- components/doc/megamenu/basicdoc.js | 4 +- components/lib/menubar/Menubar.js | 544 +++++++++++++++++++++++- components/lib/menubar/MenubarBase.js | 36 +- components/lib/menubar/MenubarSub.js | 266 +++++------- components/lib/menubar/menubar.d.ts | 18 + 6 files changed, 730 insertions(+), 183 deletions(-) diff --git a/components/doc/common/apidoc/index.json b/components/doc/common/apidoc/index.json index 1c78d5f663..22308df4b7 100644 --- a/components/doc/common/apidoc/index.json +++ b/components/doc/common/apidoc/index.json @@ -32857,6 +32857,22 @@ "default": "", "description": "An array of menuitems." }, + { + "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": "pt", "optional": true, @@ -32901,7 +32917,34 @@ }, "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": "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." + } + ] } } }, diff --git a/components/doc/megamenu/basicdoc.js b/components/doc/megamenu/basicdoc.js index a941849439..a71675bb76 100644 --- a/components/doc/megamenu/basicdoc.js +++ b/components/doc/megamenu/basicdoc.js @@ -10,8 +10,8 @@ export function BasicDoc(props) { items: [ [ { - label: 'Tadic', - items: [{ label: 'Dzeko' }, { label: 'Symanzki' }] + label: 'Video 1', + items: [{ label: 'Video 1.1' }, { label: 'Video 1.2' }] }, { label: 'Video 2', diff --git a/components/lib/menubar/Menubar.js b/components/lib/menubar/Menubar.js index 454a104600..5216de190a 100644 --- a/components/lib/menubar/Menubar.js +++ b/components/lib/menubar/Menubar.js @@ -1,9 +1,9 @@ import * as React from 'react'; -import PrimeReact, { PrimeReactContext } from '../api/Api'; +import PrimeReact, { PrimeReactContext, ariaLabel } from '../api/Api'; import { useHandleStyle } from '../componentbase/ComponentBase'; -import { useEventListener, useMountEffect, useUnmountEffect, useUpdateEffect } from '../hooks/Hooks'; +import { useEventListener, useResizeListener, useMountEffect, useUnmountEffect, useUpdateEffect } from '../hooks/Hooks'; import { BarsIcon } from '../icons/bars'; -import { IconUtils, ObjectUtils, UniqueComponentId, ZIndexUtils, classNames, mergeProps } from '../utils/Utils'; +import { IconUtils, ObjectUtils, UniqueComponentId, ZIndexUtils, classNames, mergeProps, DomHandler } from '../utils/Utils'; import { MenubarBase } from './MenubarBase'; import { MenubarSub } from './MenubarSub'; @@ -14,9 +14,20 @@ export const Menubar = React.memo( const [idState, setIdState] = React.useState(props.id); const [mobileActiveState, setMobileActiveState] = React.useState(false); + const [focused, setFocused] = React.useState(false); + const [focusedItemInfo, setFocusedItemInfo] = React.useState({ index: -1, level: 0, parentKey: '' }); + const [focusedItemId, setFocusedItemId] = React.useState(null); + const [activeItemPath, setActiveItemPath] = React.useState([]); + const [visibleItems, setVisibleItems] = React.useState([]); + const [processedItems, setProcessedItems] = React.useState([]); + const [focusTrigger, setFocusTrigger] = React.useState(false); + const [dirty, setDirty] = React.useState(false); const elementRef = React.useRef(null); const rootMenuRef = React.useRef(null); const menuButtonRef = React.useRef(null); + const searchValue = React.useRef(''); + const searchTimeout = React.useRef(null); + const reverseTrigger = React.useRef(false); const { ptm, cx, isUnstyled } = MenubarBase.setMetaData({ props, state: { @@ -27,29 +38,469 @@ export const Menubar = React.memo( useHandleStyle(MenubarBase.css.styles, isUnstyled, { name: 'menubar' }); - const [bindDocumentClickListener, unbindDocumentClickListener] = useEventListener({ + const [bindOutsideClickListener, unbindOutsideClickListener] = useEventListener({ type: 'click', listener: (event) => { - if (mobileActiveState && isOutsideClicked(event)) { - setMobileActiveState(false); + if (isOutsideClicked(event)) { + const isOutsideContainer = elementRef.current && !elementRef.current.contains(event.target); + + if (isOutsideContainer) { + hide(); + } + } + } + }); + + const [bindResizeListener, unbindResizeListener] = useResizeListener({ + listener: (event) => { + if (!DomHandler.isTouchDevice()) { + hide(event); } } }); const toggle = (event) => { + if (mobileActiveState) { + setMobileActiveState(false); + hide(); + } else { + setMobileActiveState(true); + setTimeout(() => { + show(); + }, 1); + } + event.preventDefault(); + }; - setMobileActiveState((prevMobileActive) => !prevMobileActive); + const show = () => { + setFocusedItemInfo({ index: findFirstFocusedItemIndex(), level: 0, parentKey: '' }); + + DomHandler.focus(rootMenuRef.current); + }; + + const hide = (isFocus) => { + if (mobileActiveState) { + setMobileActiveState(false); + setTimeout(() => { + DomHandler.focus(menuButtonRef.current); + }, 0); + } + + setActiveItemPath([]); + setFocusedItemInfo({ index: -1, level: 0, parentKey: '' }); + + isFocus && DomHandler.focus(rootMenuRef.current); + setDirty(false); }; - const onLeafClick = () => { - setMobileActiveState(false); + const menuButtonKeydown = (event) => { + (event.code === 'Enter' || event.code === 'Space') && toggle(event); }; const isOutsideClicked = (event) => { return rootMenuRef.current !== event.target && !rootMenuRef.current.contains(event.target) && menuButtonRef.current !== event.target && !menuButtonRef.current.contains(event.target); }; + const getItemProp = (item, name) => { + return item ? ObjectUtils.getItemValue(item[name]) : undefined; + }; + + const getItemLabel = (item) => { + return getItemProp(item, 'label'); + }; + + const isItemDisabled = (item) => getItemProp(item, 'disabled'); + + const isItemSeparator = (item) => getItemProp(item, 'separator'); + + const getProccessedItemLabel = (processedItem) => (processedItem ? getItemLabel(processedItem.item) : undefined); + + const isProccessedItemGroup = (processedItem) => processedItem && ObjectUtils.isNotEmpty(processedItem.items); + + const onFocus = (event) => { + setFocused(true); + setFocusedItemInfo(focusedItemInfo.index !== -1 ? focusedItemInfo : { index: findFirstFocusedItemIndex(), level: 0, parentKey: '' }); + props.onFocus && props.onFocus(event); + }; + + const onBlur = (event) => { + setFocused(false); + setFocusedItemInfo({ index: -1, level: 0, parentKey: '' }); + searchValue.current = ''; + setDirty(false); + props.onBlur && props.onBlur(event); + }; + + const onKeyDown = (event) => { + const metaKey = event.metaKey || event.ctrlKey; + const code = event.code; + + if (code !== 'Tab') event.preventDefault(); + + switch (code) { + case 'ArrowDown': + onArrowDownKey(event); + break; + + case 'ArrowUp': + onArrowUpKey(event); + break; + + case 'ArrowLeft': + onArrowLeftKey(event); + break; + + case 'ArrowRight': + onArrowRightKey(event); + break; + + case 'Home': + onHomeKey(event); + break; + + case 'End': + onEndKey(event); + break; + + case 'Space': + onSpaceKey(event); + break; + + case 'Enter': + onEnterKey(event); + break; + + case 'Escape': + onEscapeKey(event); + break; + + case 'Tab': + onTabKey(event); + break; + + case 'PageDown': + case 'PageUp': + case 'Backspace': + case 'ShiftLeft': + case 'ShiftRight': + break; + + default: + if (!metaKey && ObjectUtils.isPrintableCharacter(event.key)) { + searchItems(event, event.key); + } + + break; + } + }; + + const onItemChange = (event) => { + const { processedItem, isFocus } = event; + + if (ObjectUtils.isEmpty(processedItem)) return; + + const { index, key, level, parentKey, items } = processedItem; + const grouped = ObjectUtils.isNotEmpty(items); + const _activeItemPath = activeItemPath.filter((p) => p.parentKey !== parentKey && p.parentKey !== key); + + grouped && _activeItemPath.push(processedItem); + + setFocusedItemInfo({ index, level, parentKey }); + setActiveItemPath(_activeItemPath); + + grouped && setDirty(true); + isFocus && DomHandler.focus(rootMenuRef.current); + }; + + const onItemClick = (event) => { + const { originalEvent, processedItem } = event; + const grouped = isProccessedItemGroup(processedItem); + const root = ObjectUtils.isEmpty(processedItem.parent); + const selected = isSelected(processedItem); + + if (selected) { + const { index, key, level, parentKey } = processedItem; + + setActiveItemPath(activeItemPath.filter((p) => key !== p.key && key.startsWith(p.key))); + setFocusedItemInfo({ index, level, parentKey }); + + if (!grouped) { + setDirty(!root); + } + + setTimeout(() => { + DomHandler.focus(rootMenuRef.current); + + if (grouped) { + setDirty(true); + } + }, 0); + } else { + if (grouped) { + DomHandler.focus(rootMenuRef.current); + onItemChange({ originalEvent, processedItem }); + } else { + const rootProcessedItem = root ? processedItem : activeItemPath.find((p) => p.parentKey === ''); + const rootProcessedItemIndex = rootProcessedItem ? rootProcessedItem.index : -1; + + hide(originalEvent); + setFocusedItemInfo({ index: rootProcessedItemIndex, parentKey: rootProcessedItem ? rootProcessedItem.parentKey : '' }); + setMobileActiveState(false); + } + } + }; + + const onItemMouseEnter = (event) => { + if (!mobileActiveState && dirty) { + onItemChange(event); + } + }; + + const onArrowDownKey = (event) => { + const processedItem = visibleItems[focusedItemInfo.index]; + const root = processedItem ? ObjectUtils.isEmpty(processedItem.parent) : null; + + if (root) { + const grouped = isProccessedItemGroup(processedItem); + + if (grouped) { + onItemChange({ originalEvent: event, processedItem }); + setFocusedItemInfo({ index: -1, parentKey: processedItem.key }); + setTimeout(() => setFocusTrigger(true), 0); + } + } else { + const itemIndex = focusedItemInfo.index !== -1 ? findNextItemIndex(focusedItemInfo.index) : findFirstFocusedItemIndex(); + + changeFocusedItemIndex(itemIndex); + } + }; + + const onArrowUpKey = (event) => { + const processedItem = visibleItems[focusedItemInfo.index]; + const root = ObjectUtils.isEmpty(processedItem.parent); + + if (root) { + const grouped = isProccessedItemGroup(processedItem); + + if (grouped) { + onItemChange({ originalEvent: event, processedItem }); + setFocusedItemInfo({ index: -1, parentKey: processedItem.key }); + reverseTrigger.current = true; + setTimeout(() => setFocusTrigger(true), 0); + } + } else { + const parentItem = activeItemPath.find((p) => p.key === processedItem.parentKey); + + if (focusedItemInfo.index === 0) { + setFocusedItemInfo({ index: -1, parentKey: parentItem ? parentItem.parentKey : '' }); + searchValue.current = ''; + onArrowLeftKey(event); + } else { + const itemIndex = focusedItemInfo.index !== -1 ? findPrevItemIndex(focusedItemInfo.index) : findLastFocusedItemIndex(); + + changeFocusedItemIndex(itemIndex); + } + } + }; + + const onArrowLeftKey = (event) => { + const processedItem = visibleItems[focusedItemInfo.index]; + const parentItem = processedItem ? activeItemPath.find((p) => p.key === processedItem.parentKey) : null; + + if (parentItem) { + onItemChange({ originalEvent: event, processedItem: parentItem }); + setActiveItemPath(activeItemPath.filter((p) => p.key !== parentItem.key)); + } else { + const itemIndex = focusedItemInfo.index !== -1 ? findPrevItemIndex(focusedItemInfo.index) : findLastFocusedItemIndex(); + + changeFocusedItemIndex(itemIndex); + } + }; + + const onArrowRightKey = (event) => { + const processedItem = visibleItems[focusedItemInfo.index]; + const parentItem = processedItem ? activeItemPath.find((p) => p.key === processedItem.parentKey) : null; + + if (parentItem) { + const grouped = isProccessedItemGroup(processedItem); + + if (grouped) { + onItemChange({ originalEvent: event, processedItem }); + setFocusedItemInfo({ index: -1, parentKey: processedItem.key }); + setTimeout(() => setFocusTrigger(true), 0); + } + } else { + const itemIndex = focusedItemInfo.index !== -1 ? findNextItemIndex(focusedItemInfo.index) : findFirstFocusedItemIndex(); + + changeFocusedItemIndex(itemIndex); + } + }; + + const onHomeKey = (event) => { + changeFocusedItemIndex(findFirstItemIndex()); + }; + + const onEndKey = (event) => { + changeFocusedItemIndex(findLastItemIndex()); + }; + + const onEnterKey = (event) => { + if (focusedItemInfo.index !== -1) { + const element = DomHandler.findSingle(rootMenuRef.current, `li[id="${`${focusedItemId}`}"]`); + const anchorElement = element && DomHandler.findSingle(element, 'a[data-pc-section="action"]'); + + anchorElement ? anchorElement.click() : element && element.click(); + } + }; + + const onSpaceKey = (event) => { + onEnterKey(event); + }; + + const onEscapeKey = (event) => { + hide(true); + setFocusedItemInfo({ focusedItemInfo, index: findFirstFocusedItemIndex() }); + }; + + const onTabKey = (event) => { + if (focusedItemInfo.index !== -1) { + const processedItem = visibleItems[focusedItemInfo.index]; + const grouped = isProccessedItemGroup(processedItem); + + !grouped && onItemChange({ originalEvent: event, processedItem }); + } + + hide(); + }; + + const isItemMatched = (processedItem) => { + return isValidItem(processedItem) && getProccessedItemLabel(processedItem).toLocaleLowerCase().startsWith(searchValue.current.toLocaleLowerCase()); + }; + + const isValidItem = (processedItem) => { + return !!processedItem && !isItemDisabled(processedItem.item) && !isItemSeparator(processedItem.item); + }; + + const isValidSelectedItem = (processedItem) => { + return isValidItem(processedItem) && isSelected(processedItem); + }; + + const isSelected = (processedItem) => { + return activeItemPath.some((p) => p.key === processedItem.key); + }; + + const findFirstItemIndex = () => { + return visibleItems.findIndex((processedItem) => isValidItem(processedItem)); + }; + + const findLastItemIndex = () => { + return ObjectUtils.findLastIndex(visibleItems, (processedItem) => isValidItem(processedItem)); + }; + + const findNextItemIndex = (index) => { + const matchedItemIndex = index < visibleItems.length - 1 ? visibleItems.slice(index + 1).findIndex((processedItem) => isValidItem(processedItem)) : -1; + + return matchedItemIndex > -1 ? matchedItemIndex + index + 1 : index; + }; + + const findPrevItemIndex = (index) => { + const matchedItemIndex = index > 0 ? ObjectUtils.findLastIndex(visibleItems.slice(0, index), (processedItem) => isValidItem(processedItem)) : -1; + + return matchedItemIndex > -1 ? matchedItemIndex : index; + }; + + const findSelectedItemIndex = () => { + return visibleItems.findIndex((processedItem) => isValidSelectedItem(processedItem)); + }; + + const findFirstFocusedItemIndex = () => { + const selectedIndex = findSelectedItemIndex(); + + return selectedIndex < 0 ? findFirstItemIndex() : selectedIndex; + }; + + const findLastFocusedItemIndex = () => { + const selectedIndex = findSelectedItemIndex(); + + return selectedIndex < 0 ? findLastItemIndex() : selectedIndex; + }; + + const searchItems = (event, char) => { + searchValue.current = (searchValue.current || '') + char; + + let itemIndex = -1; + let matched = false; + + if (focusedItemInfo.index !== -1) { + itemIndex = visibleItems.slice(focusedItemInfo.index).findIndex((processedItem) => isItemMatched(processedItem)); + itemIndex = itemIndex === -1 ? visibleItems.slice(0, focusedItemInfo.index).findIndex((processedItem) => isItemMatched(processedItem)) : itemIndex + focusedItemInfo.index; + } else { + itemIndex = visibleItems.findIndex((processedItem) => isItemMatched(processedItem)); + } + + if (itemIndex !== -1) { + matched = true; + } + + if (itemIndex === -1 && focusedItemInfo.index === -1) { + itemIndex = findFirstFocusedItemIndex(); + } + + if (itemIndex !== -1) { + changeFocusedItemIndex(itemIndex); + } + + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + + searchTimeout.current = setTimeout(() => { + searchValue.current = ''; + searchTimeout.current = null; + }, 500); + + return matched; + }; + + const changeFocusedItemIndex = (index) => { + if (focusedItemInfo.index !== index) { + setFocusedItemInfo({ ...focusedItemInfo, index }); + scrollInView(); + } + }; + + const scrollInView = (index = -1) => { + const id = index !== -1 ? `${idState}_${index}` : focusedItemId; + const element = DomHandler.findSingle(rootMenuRef.current, `li[id="${id}"]`); + + if (element) { + element.scrollIntoView && element.scrollIntoView({ block: 'nearest', inline: 'start' }); + } + }; + + const createProcessedItems = (items, level = 0, parent = {}, parentKey = '') => { + const _processedItems = []; + + items && + items.forEach((item, index) => { + const key = (parentKey !== '' ? parentKey + '_' : '') + index; + const newItem = { + item, + index, + level, + key, + parent, + parentKey + }; + + newItem['items'] = createProcessedItems(item.items, level + 1, newItem, key); + _processedItems.push(newItem); + }); + + return _processedItems; + }; + useMountEffect(() => { if (!idState) { setIdState(UniqueComponentId()); @@ -58,14 +509,54 @@ export const Menubar = React.memo( useUpdateEffect(() => { if (mobileActiveState) { + bindOutsideClickListener(); + bindResizeListener(); ZIndexUtils.set('menu', rootMenuRef.current, (context && context.autoZIndex) || PrimeReact.autoZIndex, (context && context.zIndex['menu']) || PrimeReact.zIndex['menu']); - bindDocumentClickListener(); } else { - unbindDocumentClickListener(); + unbindResizeListener(); + unbindOutsideClickListener(); ZIndexUtils.clear(rootMenuRef.current); } }, [mobileActiveState]); + useUpdateEffect(() => { + const itemsToProcess = props.model || []; + const processed = createProcessedItems(itemsToProcess, 0, null, ''); + + setProcessedItems(processed); + }, [props.model]); + + useUpdateEffect(() => { + const processedItem = activeItemPath.find((p) => p.key === focusedItemInfo.parentKey); + const _processedItems = processedItem ? processedItem.items : processedItems; + + setVisibleItems(_processedItems); + }, [activeItemPath, focusedItemInfo, processedItems]); + + useUpdateEffect(() => { + if (ObjectUtils.isNotEmpty(activeItemPath)) { + bindOutsideClickListener(); + bindResizeListener(); + } else { + unbindOutsideClickListener(); + unbindResizeListener(); + } + }, [activeItemPath]); + + useUpdateEffect(() => { + if (focusTrigger) { + const itemIndex = focusedItemInfo.index !== -1 ? findNextItemIndex(focusedItemInfo.index) : reverseTrigger.current ? findLastItemIndex() : findFirstFocusedItemIndex(); + + changeFocusedItemIndex(itemIndex); + reverseTrigger.current = false; + setFocusTrigger(false); + } + }, [focusTrigger]); + + useUpdateEffect(() => { + setFocusedItemId(focusedItemInfo.index !== -1 ? `${idState}${ObjectUtils.isNotEmpty(focusedItemInfo.parentKey) ? '_' + focusedItemInfo.parentKey : ''}_${focusedItemInfo.index}` : null); + }, [focusedItemInfo]); + useUnmountEffect(() => { ZIndexUtils.clear(rootMenuRef.current); }); @@ -119,9 +610,15 @@ export const Menubar = React.memo( { ref: menuButtonRef, href: '#', + tabIndex: '0', + 'aria-haspopup': props.model && props.model.lentgh > 0 ? true : false, + 'aria-expanded': mobileActiveState, + 'aria-label': ariaLabel('navigation'), + 'aria-controls': idState, role: 'button', tabIndex: 0, className: cx('button'), + onKeyDown: (e) => menuButtonKeydown(e), onClick: (e) => toggle(e) }, ptm('button') @@ -140,10 +637,33 @@ export const Menubar = React.memo( const start = createStartContent(); const end = createEndContent(); const menuButton = createMenuButton(); - const submenu = ; + + const submenu = ( + + ); const rootProps = mergeProps( { id: props.id, + ref: elementRef, className: classNames(props.className, cx('root', { mobileActiveState })), style: props.style }, diff --git a/components/lib/menubar/MenubarBase.js b/components/lib/menubar/MenubarBase.js index 631717a5e9..7b9d7a93f9 100644 --- a/components/lib/menubar/MenubarBase.js +++ b/components/lib/menubar/MenubarBase.js @@ -13,10 +13,11 @@ const classes = { icon: 'p-menuitem-icon', label: 'p-menuitem-text', submenuIcon: 'p-submenu-icon', - menuitem: ({ item, activeItemState }) => classNames('p-menuitem', { 'p-menuitem-active': activeItemState === item }), + menuitem: ({ active, focused, disabled }) => classNames('p-menuitem', { 'p-menuitem-active p-highlight': active, 'p-focus': focused, 'p-disabled': disabled }), menu: 'p-menubar-root-list', + content: 'p-menuitem-content', submenu: 'p-submenu-list', - action: ({ item }) => classNames('p-menuitem-link', { 'p-disabled': item.disabled }) + action: ({ disabled }) => classNames('p-menuitem-link', { 'p-disabled': disabled }) }; const styles = ` @@ -25,13 +26,13 @@ const styles = ` display: flex; align-items: center; } - + .p-menubar ul { margin: 0; padding: 0; list-style: none; } - + .p-menubar .p-menuitem-link { cursor: pointer; display: flex; @@ -40,52 +41,51 @@ const styles = ` overflow: hidden; position: relative; } - + .p-menubar .p-menuitem-text { line-height: 1; } - + .p-menubar .p-menuitem { position: relative; } - + .p-menubar-root-list { display: flex; align-items: center; flex-wrap: wrap; } - + .p-menubar-root-list > li ul { display: none; z-index: 1; } - + .p-menubar-root-list > .p-menuitem-active > .p-submenu-list { display: block; } - + .p-menubar .p-submenu-list { display: none; position: absolute; z-index: 1; } - + .p-menubar .p-submenu-list > .p-menuitem-active > .p-submenu-list { display: block; left: 100%; top: 0; } - - .p-menubar .p-submenu-list .p-menuitem-link .p-submenu-icon { + + .p-menubar .p-submenu-list .p-menuitem .p-menuitem-content .p-menuitem-link .p-submenu-icon { margin-left: auto; } - - .p-menubar .p-menubar-custom, + .p-menubar .p-menubar-end { margin-left: auto; align-self: center; } - + .p-menubar-button { display: none; cursor: pointer; @@ -104,6 +104,10 @@ export const MenubarBase = ComponentBase.extend({ style: null, className: null, start: null, + ariaLabel: null, + ariaLabelledBy: null, + onFocus: null, + onBlur: null, submenuIcon: null, menuIcon: null, end: null, diff --git a/components/lib/menubar/MenubarSub.js b/components/lib/menubar/MenubarSub.js index 981b9456b8..99fdcbc240 100644 --- a/components/lib/menubar/MenubarSub.js +++ b/components/lib/menubar/MenubarSub.js @@ -1,52 +1,43 @@ import * as React from 'react'; -import { useEventListener, useMountEffect, useUpdateEffect } from '../hooks/Hooks'; import { AngleDownIcon } from '../icons/angledown'; import { AngleRightIcon } from '../icons/angleright'; import { Ripple } from '../ripple/Ripple'; -import { DomHandler, IconUtils, ObjectUtils, classNames, mergeProps } from '../utils/Utils'; +import { IconUtils, ObjectUtils, classNames, mergeProps } from '../utils/Utils'; export const MenubarSub = React.memo( React.forwardRef((props, ref) => { - const [activeItemState, setActiveItemState] = React.useState(null); const { ptm, cx } = props; - const getPTOptions = (item, key) => { + const getPTOptions = (processedItem, key, index) => { return ptm(key, { props, hostName: props.hostName, context: { - active: activeItemState === item + item: processedItem, + index, + active: isItemActive(processedItem), + focused: isItemFocused(processedItem), + disabled: isItemDisabled(processedItem), + level: props.level } }); }; - const [bindDocumentClickListener] = useEventListener({ - type: 'click', - listener: (event) => { - if (ref && ref.current && !ref.current.contains(event.target)) { - setActiveItemState(null); - } - } - }); - const onItemMouseEnter = (event, item) => { - if (item.disabled || props.mobileActive) { + if (isItemDisabled(item) || props.mobileActive) { event.preventDefault(); return; } - if (props.root) { - if (activeItemState || props.popup) { - setActiveItemState(item); - } - } else { - setActiveItemState(item); - } + props.onItemMouseEnter && props.onItemMouseEnter({ originalEvent: event, processedItem: item }); }; - const onItemClick = (event, item) => { - if (item.disabled) { + const onItemClick = (event, processedItem) => { + const item = processedItem.item; + + if (isItemDisabled(processedItem)) { + console.log('ho'); event.preventDefault(); return; @@ -63,117 +54,48 @@ export const MenubarSub = React.memo( }); } - if (item.items) activeItemState && item === activeItemState ? setActiveItemState(null) : setActiveItemState(item); - else onLeafClick(); + onLeafClick({ originalEvent: event, processedItem, isFocus: true }); }; - const onItemKeyDown = (event, item) => { - const listItem = event.currentTarget.parentElement; - - switch (event.which) { - //down - case 40: - if (props.root) item.items && expandSubmenu(item, listItem); - else navigateToNextItem(listItem); - - event.preventDefault(); - break; - - //up - case 38: - !props.root && navigateToPrevItem(listItem); - event.preventDefault(); - break; - - //right - case 39: - if (props.root) { - const nextItem = findNextItem(listItem); - - nextItem && nextItem.children[0].focus(); - } else { - item.items && expandSubmenu(item, listItem); - } - - event.preventDefault(); - break; - - //left - case 37: - props.root && navigateToPrevItem(listItem); - event.preventDefault(); - break; - - default: - break; - } - - props.onKeyDown && props.onKeyDown(event, listItem); + const onLeafClick = (event) => { + props.onLeafClick && props.onLeafClick(event); }; - const onChildItemKeyDown = (event, childListItem) => { - if (props.root) { - //up - if (event.which === 38 && childListItem.previousElementSibling == null) { - collapseMenu(childListItem); - } - } else { - //left - if (event.which === 37) { - collapseMenu(childListItem); - } - } + const getItemId = (processedItem) => { + return `${props.id}_${processedItem.key}`; }; - const expandSubmenu = (item, listItem) => { - setActiveItemState(item); - - setTimeout(() => { - listItem.children[1].children[0].children[0].focus(); - }, 50); + const getItemProp = (processedItem, name, params) => { + return processedItem && processedItem.item ? ObjectUtils.getItemValue(processedItem.item[name], params) : undefined; }; - const collapseMenu = (listItem) => { - setActiveItemState(null); - listItem.parentElement.previousElementSibling.focus(); + const isItemActive = (processedItem) => { + return props.activeItemPath.some((path) => path.key === processedItem.key); }; - const navigateToNextItem = (listItem) => { - const nextItem = findNextItem(listItem); - - nextItem && nextItem.children[0].focus(); + const isItemVisible = (processedItem) => { + return getItemProp(processedItem, 'visible') !== false; }; - const navigateToPrevItem = (listItem) => { - const prevItem = findPrevItem(listItem); - - prevItem && prevItem.children[0].focus(); + const isItemDisabled = (processedItem) => { + return getItemProp(processedItem, 'disabled'); }; - const findNextItem = (item) => { - const nextItem = item.nextElementSibling; - - return nextItem ? (DomHandler.getAttribute(nextItem, '[data-p-disabled="true"]') || !DomHandler.getAttribute(nextItem, '[data-pc-section="menuitem"]') ? findNextItem(nextItem) : nextItem) : null; + const isItemFocused = (processedItem) => { + return props.focusedItemId === getItemId(processedItem); }; - const findPrevItem = (item) => { - const prevItem = item.previousElementSibling; - - return prevItem ? (DomHandler.getAttribute(prevItem, '[data-p-disabled="true"]') || !DomHandler.getAttribute(prevItem, '[data-pc-section="menuitem"]') ? findPrevItem(prevItem) : prevItem) : null; + const isItemGroup = (processedItem) => { + return ObjectUtils.isNotEmpty(processedItem.items); }; - const onLeafClick = () => { - setActiveItemState(null); - props.onLeafClick && props.onLeafClick(); + const getAriaSetSize = () => { + return props.model.filter((processedItem) => isItemVisible(processedItem) && !getItemProp(processedItem, 'separator')).length; }; - useMountEffect(() => { - bindDocumentClickListener(); - }); - - useUpdateEffect(() => { - !props.parentActive && setActiveItemState(null); - }, [props.parentActive]); + const getAriaPosInset = (index) => { + return index - props.model.slice(0, index).filter((processedItem) => isItemVisible(processedItem) && getItemProp(processedItem, 'separator')).length + 1; + }; const createSeparator = (index) => { const key = props.id + '_separator_' + index; @@ -190,20 +112,24 @@ export const MenubarSub = React.memo( return
  • ; }; - const createSubmenu = (item, index) => { - if (item.items) { + const createSubmenu = (processedItem) => { + const items = processedItem && processedItem.items; + + if (items) { return ( ); @@ -212,54 +138,62 @@ export const MenubarSub = React.memo( return null; }; - const createMenuitem = (item, index) => { - if (item.visible === false) { + const createMenuitem = (processedItem, index) => { + const item = processedItem.item; + + if (!isItemVisible(processedItem)) { return null; } - const key = item.id || props.id + '_' + index; - const linkClassName = classNames('p-menuitem-link', { 'p-disabled': item.disabled }); - const iconClassName = classNames('p-menuitem-icon', item.icon); + const key = getItemId(processedItem); + const active = isItemActive(processedItem); + const focused = isItemFocused(processedItem); + const disabled = isItemDisabled(processedItem) || false; + const group = isItemGroup(processedItem); + + const linkClassName = classNames('p-menuitem-link', { 'p-disabled': disabled }); + const iconClassName = classNames('p-menuitem-icon', getItemProp(processedItem, 'icon')); const iconProps = mergeProps( { className: cx('icon') }, - getPTOptions(item, 'icon') + getPTOptions(processedItem, 'icon', index) ); const icon = IconUtils.getJSXIcon(item.icon, { ...iconProps }, { props: props.menuProps }); const labelProps = mergeProps( { className: cx('label') }, - getPTOptions(item, 'label') + getPTOptions(processedItem, 'label', index) ); const label = item.label && {item.label}; + const items = getItemProp(processedItem, 'items'); const submenuIconClassName = 'p-submenu-icon'; const submenuIconProps = mergeProps( { className: cx('submenuIcon') }, - getPTOptions(item, 'submenuIcon') + getPTOptions(processedItem, 'submenuIcon', index) ); const submenuIcon = - item.items && + items && IconUtils.getJSXIcon( !props.root ? props.submenuIcon || : props.submenuIcon || , { ...submenuIconProps }, { props: { menuProps: props.menuProps, ...props } } ); - const submenu = createSubmenu(item, index); + const submenu = createSubmenu(processedItem); const actionProps = mergeProps( { href: item.url || '#', - role: 'menuitem', - className: cx('action', { item }), - target: item.target, - 'aria-haspopup': item.items != null, - onClick: (event) => onItemClick(event, item), - onKeyDown: (event) => onItemKeyDown(event, item) + tabIndex: '-1', + 'aria-hidden': 'true', + className: cx('action', { disabled }), + onFocus: (event) => event.stopPropagation(), + target: getItemProp(processedItem, 'target'), + 'aria-haspopup': items != null }, - getPTOptions(item, 'action') + getPTOptions(processedItem, 'action', index) ); let content = ( @@ -271,10 +205,10 @@ export const MenubarSub = React.memo( ); - if (item.template) { + let template = getItemProp(processedItem, 'template'); + + if (template) { const defaultContentOptions = { - onClick: (event) => onItemClick(event, item), - onKeyDown: (event) => onItemKeyDown(event, item), className: linkClassName, labelClassName: 'p-menuitem-text', iconClassName, @@ -283,31 +217,51 @@ export const MenubarSub = React.memo( props }; - content = ObjectUtils.getJSXElement(item.template, item, defaultContentOptions); + content = ObjectUtils.getJSXElement(processedItem, item, defaultContentOptions); } + const contentProps = mergeProps( + { + onClick: (event) => onItemClick(event, processedItem), + onMouseEnter: (event) => onItemMouseEnter(event, processedItem), + className: cx('content') + }, + getPTOptions(processedItem, 'content', index) + ); + + const itemClassName = getItemProp(processedItem, 'className'); + const menuitemProps = mergeProps( { id: key, key, - role: 'none', - className: classNames(item.className, cx('menuitem', { item, activeItemState })), - onMouseEnter: (event) => onItemMouseEnter(event, item), - 'data-p-disabled': item.disabled || false + role: 'menuitem', + 'aria-label': item.label, + 'aria-disabled': disabled, + 'aria-expanded': group ? active : undefined, + 'aria-haspopup': group && !item.url ? 'menu' : undefined, + 'aria-level': props.level + 1, + 'aria-setsize': getAriaSetSize(), + 'aria-posinset': getAriaPosInset(index), + 'data-p-highlight': active, + 'data-p-focused': focused, + 'data-p-disabled': disabled, + className: classNames(itemClassName, cx('menuitem', { active, focused, disabled })), + 'data-p-disabled': disabled || false }, - getPTOptions(item, 'menuitem') + getPTOptions(processedItem, 'menuitem', index) ); return (
  • - {content} +
    {content}
    {submenu}
  • ); }; - const createItem = (item, index) => { - return item.separator ? createSeparator(index) : createMenuitem(item, index); + const createItem = (processedItem, index) => { + return getItemProp(processedItem, 'separator') ? createSeparator(index) : createMenuitem(processedItem, index); }; const createMenu = () => { @@ -316,12 +270,20 @@ export const MenubarSub = React.memo( const role = props.root ? 'menubar' : 'menu'; const ptKey = props.root ? 'menu' : 'submenu'; + const tabIndex = props.root ? '0' : null; const submenu = createMenu(); const menuProps = mergeProps( { ref, className: cx(ptKey), - style: !props.root && { display: props.parentActive ? 'block' : 'none' }, + level: props.level, + onFocus: props.onFocus, + onBlur: props.onBlur, + onKeyDown: props.onKeyDown, + id: props.id, + tabIndex: tabIndex, + 'aria-activedescendant': props.ariaActivedescendant, + style: props.style, role }, ptm(ptKey) diff --git a/components/lib/menubar/menubar.d.ts b/components/lib/menubar/menubar.d.ts index 788a22beae..181e8cd2f6 100644 --- a/components/lib/menubar/menubar.d.ts +++ b/components/lib/menubar/menubar.d.ts @@ -127,6 +127,24 @@ export interface MenubarProps extends Omit | undefined; + /** + * Used to define a string that labels the component. + */ + ariaLabel?: string | undefined; + /** + * Establishes relationships between the component and label(s) where its value should be one or more element IDs. + */ + ariaLabelledBy?: string | undefined; + /** + * Callback to invoke when menu receives focus. + * @param {React.SyntheticEvent} event - Browser event. + */ + onFocus?(event: React.SyntheticEvent): void; + /** + * Callback to invoke when menu loses focus. + * @param {React.SyntheticEvent} event - Browser event. + */ + onBlur?(event: React.SyntheticEvent): void; /** * The template of trailing element. */