UNPKG

@momentum-ui/react-collaboration

Version:

Cisco Momentum UI Framework for React Collaboration Applications

338 lines (293 loc) 10.4 kB
import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import omit from 'lodash/omit'; import mapContextToProps from '@restart/context/mapContextToProps'; import qsa from 'dom-helpers/query/querySelectorAll'; import { UIDReset } from 'react-uid'; import SelectableContext from '../SelectableContext'; import ListContext from '../ListContext'; import MenuContext from '../MenuContext'; /** * @deprecated - Components in the legacy folder (/src/legacy) are deprecated. Please use a component from the components folder (/src/components) instead. Legacy components may not follow accessibility standards. **/ class Menu extends React.Component { constructor(props) { super(props); this.state = { currentElements: null, activeElement: null, listContext: { active: [], focus: null, ariaConfig: this.props.ariaConfig, }, selectContext: { parentKeyDown: this.handleKeyDown, parentOnSelect: this.handleSelect, }, }; } componentDidMount() { const menuItems = this.getFocusableItems(this.menuNode); menuItems.length && this.setFocus(menuItems[0], true); } componentDidUpdate(prevProps, prevState) { if (!this.menuNode) return; const { focusFirst } = this.props; const { activeElement, listContext } = this.state; if (prevState.listContext !== listContext) { if (activeElement && this._selectRefocus) { let activeNode = []; try { activeNode = ReactDOM.findDOMNode(activeElement); } catch (error) { activeNode = ReactDOM.findDOMNode(prevState.activeElement); } const overlayItems = this.getFocusableItems(activeNode, '.md-menu-item-container'); const items = overlayItems.length && this.getFocusableItems(overlayItems[0]); this._selectRefocus = false; items.length && this.setFocus(items[0], false, true); } else if (listContext.focus !== prevState.listContext.focus) { if (!prevState.listContext.focus && !focusFirst) { return; } this.menuNode.querySelector(`[data-md-event-key="${listContext.focus}"]`)?.focus(); } } } getFocusableItems = (node, selector) => { const defaultSelector = '.md-list-item:not(.disabled):not(:disabled)' + ':not(.md-list-item--read-only)'; return qsa(node, selector || defaultSelector); }; getIncludesFirstCharacter = (str, char) => str.charAt(0).toLowerCase().includes(char); getNextFocusedChild(element, current, offset) { if (!element) return null; const { currentElements, listContext } = this.state; const items = this.getFocusableItems(element); const possibleIndex = items.indexOf(current) + offset; const getIndex = () => { if (possibleIndex < 0) { return items.length - 1; } else if (possibleIndex > items.length - 1) { return 0; } else return possibleIndex; }; const newFocusKey = items[getIndex()].attributes['data-md-event-key'].value; newFocusKey !== listContext.focus && this.setState({ currentElements: !currentElements.length ? [...newFocusKey] : [...currentElements.slice(0, currentElements.length - 1), newFocusKey], listContext: { ...listContext, focus: newFocusKey, }, }); } handleSelect = (e, opts) => { const { onSelect, parentOnSelect } = this.props; const { eventKey, element } = opts; const { children } = element.props; this._selectRefocus = true; this.setState( (state) => ({ activeElement: children ? element : null, currentElements: children ? [eventKey] : [state.currentElements[0]], listContext: { ...state.listContext, focus: children ? eventKey : state.currentElements[0], active: [eventKey], }, }), () => { onSelect && onSelect(e, { eventKey, element }); parentOnSelect && parentOnSelect(e, { eventKey, element }); } ); }; setFocus = (child, isParent, isChild) => { const { currentElements } = this.state; const getCurrentElements = () => { if (isParent) { return [child.attributes['data-md-event-key'].value]; } else if (isChild) { return currentElements.concat(child.attributes['data-md-event-key'].value); } else null; }; this.setState((state) => ({ currentElements: getCurrentElements(), listContext: { ...state.listContext, focus: child.attributes['data-md-event-key'].value, }, })); }; setFocusByFirstCharacter = (element, char) => { const { currentElements, listContext } = this.state; const items = this.getFocusableItems(element); const focusIdx = (listContext.focus && items.indexOf(element.querySelector(`[data-md-event-key="${listContext.focus}"]`))) || 0; const length = (items.length && items.length - 1) || 0; const newFocusKey = items.reduce((agg, item, idx, arr) => { const index = focusIdx + idx + 1 > length ? Math.abs(focusIdx + idx - length) : focusIdx + idx + 1; return !agg && arr[index].attributes['data-md-keyboard-key'] && arr[index].attributes['data-md-keyboard-key'].value && this.getIncludesFirstCharacter(arr[index].attributes['data-md-keyboard-key'].value, char) ? arr[index].attributes['data-md-event-key'].value : agg; }, null); typeof newFocusKey === 'string' && newFocusKey !== focus && this.setState((state) => ({ currentElements: !currentElements.length ? [...newFocusKey] : [...currentElements.slice(0, currentElements.length - 1), newFocusKey], listContext: { ...state.listContext, focus: newFocusKey, }, })); }; setFocusToLimit(element, target) { if (!element) return null; const { currentElements, listContext } = this.state; const items = this.getFocusableItems(element); const newFocusKey = items[target === 'start' ? 0 : items.length - 1].attributes['data-md-event-key'].value; newFocusKey !== listContext.focus && this.setState({ currentElements: !currentElements.length ? [...newFocusKey] : [...currentElements.slice(0, currentElements.length - 1), newFocusKey], listContext: { ...listContext, focus: newFocusKey, }, }); } handleKeyDown = (e, opts) => { const { element } = opts; const { activeElement, currentElements } = this.state; const char = e.key; const target = e.currentTarget; const activeParent = activeElement ? qsa(ReactDOM.findDOMNode(activeElement), '.md-menu-item-container')[0] : this.menuNode; const isPrintableCharacter = (char) => { return char.length === 1 && char.match(/\S/); }; let flag = false; let stopImmediatePropagation = false; switch (e.which) { case 38: //up this.getNextFocusedChild(activeParent, target, -1); flag = true; break; case 40: //down this.getNextFocusedChild(activeParent, target, 1); flag = true; break; case 39: //right element.constructor && element.constructor.displayName && element.constructor.displayName === 'SubMenu' && this.handleSelect(e, opts); flag = true; break; case 27: case 37: //escape or left if (currentElements.length - 1) { stopImmediatePropagation = true; this.setState((state) => ({ currentElements: state.currentElements.slice(0, currentElements.length - 1), activeElement: null, listContext: { focus: state.currentElements.length ? state.currentElements[0] : state.listContext.focus, active: [], }, })); } flag = true; break; case 33: case 36: //home or page up this.setFocusToLimit(activeParent, 'start'); flag = true; break; case 34: case 35: //end or page down this.setFocusToLimit(activeParent, 'end'); flag = true; break; default: if (isPrintableCharacter(char)) { this.setFocusByFirstCharacter(activeParent, char); flag = true; } break; } if (flag) { e.stopPropagation(); e.preventDefault(); stopImmediatePropagation && e.nativeEvent.stopImmediatePropagation(); } }; render() { const { ariaLabel, children, className, ...props } = this.props; const { listContext, selectContext } = this.state; const otherProps = omit({ ...props }, ['ariaConfig', 'focusFirst', 'parentOnSelect']); return ( <SelectableContext.Provider value={selectContext}> <ListContext.Provider value={listContext}> <div className={ 'md-menu' + ' md-menu-item-container' + `${(className && ` ${className}`) || ''}` } aria-label={ariaLabel} ref={(ref) => (this.menuNode = ref)} role="menubar" tabIndex={-1} {...otherProps} > <UIDReset>{children}</UIDReset> </div> </ListContext.Provider> </SelectableContext.Provider> ); } } Menu.propTypes = { /** @prop Text to display for accessibility features | '' */ ariaLabel: PropTypes.string, /** @prop Accessibility Configuration Object */ ariaConfig: PropTypes.object, /** @prop Children nodes to render inside Menu | null */ children: PropTypes.node, /** @prop Optional css class name | '' */ className: PropTypes.string, /** @prop Sets first Menu item to have focus | true */ focusFirst: PropTypes.bool, /** @prop Callback function invoked when user selects | null */ onSelect: PropTypes.func, // Internal Context Use Only parentOnSelect: PropTypes.func, }; Menu.defaultProps = { ariaLabel: '', ariaConfig: null, children: null, className: '', focusFirst: true, onSelect: null, parentOnSelect: null, }; Menu.displayName = 'Menu'; export default mapContextToProps(MenuContext, (context) => context, Menu);