UNPKG

@momentum-ui/react-collaboration

Version:

Cisco Momentum UI Framework for React Collaboration Applications

459 lines (415 loc) 16.2 kB
/** @component list-item */ import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import omit from 'lodash/omit'; import { UIDConsumer } from 'react-uid'; import SelectableContext, { makeKeyboardKey } from '../SelectableContext'; import ListContext from '../ListContext'; import mapContextToProps from '@restart/context/mapContextToProps'; /** * @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 ListItem extends React.Component { componentDidMount() { const { focus, refName, focusOnLoad } = this.props; this.verifyStructure(); focusOnLoad && focus && this[refName] && this[refName].focus(); } checkElements = (tag) => { const children = Object.values(ReactDOM.findDOMNode(this).childNodes); return this.countDOMChildren(children, tag); }; countDOMChildren = (children, tag) => children.reduce( (agg, child) => (child.tagName === tag ? { ...agg, count: (agg.count += 1) } : agg), { count: 0, children: children.length } ); getChildrenElements = (nameArr) => { const { children } = this.props; let elementCount = 0; React.Children.forEach(children, (child) => { if (child && child.type && nameArr.includes(child.type.displayName)) { return elementCount++; } }); return ( elementCount && { length: elementCount, } ); }; handleClick = (e, eventKey) => { const { disabled, label, onClick, parentOnSelect, value } = this.props; if (disabled) { e.preventDefault(); e.stopPropagation(); } e.persist(); onClick && onClick(e); parentOnSelect && parentOnSelect(e, { value, label, eventKey }); }; changeTabIndex = (tabbableChildren, index) => { for (let i = 0; i < tabbableChildren.length; i++) { if (tabbableChildren[i].tabIndex === (index === 0 ? -1 : 0)) { tabbableChildren[i].tabIndex = index; } } }; handleKeyDown = (e, eventKey) => { const { disabled, onKeyDown, parentKeyDown, value, label, focusLockTabbableChildren, focusLockTabbableChildrenProps, } = this.props; if (disabled) { e.preventDefault(); e.stopPropagation(); } const { tabbableChildrenQuery } = focusLockTabbableChildrenProps; if (focusLockTabbableChildren && e.target) { const currListItem = e.target.closest('.md-list-item'); if (currListItem) { const tabbableChildren = currListItem.querySelectorAll(tabbableChildrenQuery); if (tabbableChildren.length) { if (e.keyCode === 9 && !e.shiftKey) { // TAB only // only allow focus of tabbable children if TAB on the current listitem if (e.target.classList.contains('md-list-item')) { this.changeTabIndex(tabbableChildren, 0); } } else if (e.keyCode === 9 && e.shiftKey) { // SHIFT + TAB // If we are on one of the tabbable children if (e.target === tabbableChildren[0]) { e.preventDefault(); e.stopPropagation(); // focus on the tabbable children's associated lisitem e.target.closest('.md-list-item').focus(); // If we are on a listitem, SHIFT + TAB exits the list // so we change tabindex of children to -1 } else if (e.target.classList.contains('md-list-item')) { this.changeTabIndex(tabbableChildren, -1); } } else if (e.keyCode === 38 || e.keyCode == 40) { // UP/DOWN guarantees change of listitem, so we change tabindex of children to -1 this.changeTabIndex(tabbableChildren, -1); } } } } e.persist(); onKeyDown && onKeyDown(e); parentKeyDown && parentKeyDown(e, { value, label, eventKey }); }; handleKeyUp = (e, eventKey) => { const { disabled, onKeyUp, parentKeyUp, value, label } = this.props; if (disabled) { e.preventDefault(); e.stopPropagation(); } e.persist(); onKeyUp && onKeyUp(e); parentKeyUp && parentKeyUp(e, { value, label, eventKey }); }; handleBlur = (e) => { const { onBlur, focusLockTabbableChildren, focusLockTabbableChildrenProps } = this.props; const { tabbableChildrenQuery, portalNodeQuery, tabbableChildrenHasPopover } = focusLockTabbableChildrenProps; // For when you click or navigate away from the current listitem // Cleans up tabindex="0" before you navigate away if (focusLockTabbableChildren) { if (e.target && e.relatedTarget) { const isInThisList = e.relatedTarget.closest(portalNodeQuery); // The elt getting focus is in the current List if not undefined const listItemNode = ReactDOM.findDOMNode(this); const tabbableChildren = listItemNode.querySelectorAll(tabbableChildrenQuery); if (tabbableChildren.length) { if (isInThisList) { const relatedTargetListItem = e.relatedTarget.closest('.md-list-item'); // The new focus is a ListItem if not undefined const targetListItem = e.target.closest('.md-list-item'); // The current focus is a ListItem if not undefined if ( tabbableChildrenHasPopover && focusLockTabbableChildrenProps.tabbableChildSpawnedPopoverQuery ) { // If the tabbable children in this ListItem has Popovers const { tabbableChildSpawnedPopoverQuery } = focusLockTabbableChildrenProps; const targetIsSpawnedPopover = e.target.closest(tabbableChildSpawnedPopoverQuery); // The current focus is a EventOverlay if not undefined const relatedTargetIsSpawnedPopover = e.relatedTarget.closest( tabbableChildSpawnedPopoverQuery ); // The new focus is a EventOverlay if not undefined // from this ListItem or a EventOverlay spawned by one of the tabbable children in this ListItem if (targetListItem === listItemNode || targetIsSpawnedPopover) { // If the new focus is not the same as this ListItem or not a spawned EventOverlay, we left the current ListItem // Make tabindex="-1" if (!relatedTargetIsSpawnedPopover && relatedTargetListItem !== listItemNode) { this.changeTabIndex(tabbableChildren, -1); } } } else { // If the tabbable children in this ListItem has no Popovers // If the new focus is not the same as this ListItem or the current focus is not the same as this ListItem, we left the current ListItem // Make tabindex="-1" if (targetListItem !== listItemNode && relatedTargetListItem !== listItemNode) { this.changeTabIndex(tabbableChildren, -1); } } } else { this.changeTabIndex(tabbableChildren, -1); } } } } onBlur && onBlur(e); }; verifyStructure() { if (!this.props.children) return; const anchorCount = this.checkElements('A'); const checkAllChildren = this.getChildrenElements(['ListItemSection', 'EventOverlay']); const checkSectionChildren = this.getChildrenElements(['ListItemSection']); if (anchorCount.count > 1) { throw new Error('Only 1 primary child anchor tag may be used with ListItem component'); } else if (anchorCount.count === 1 && anchorCount.children > 1) { throw new Error('Anchor tag can not have sibling'); } if (!checkAllChildren) { return; } else if (checkSectionChildren.length > 3) { throw new Error( `Only 3 ListItemSection components can be used as children. You've used ${checkSectionChildren.length}` ); } } render() { const { active, children, className, customAnchorNode, customRefProp, disabled, eventKey, focus, isReadOnly, keyboardKey, label, link, refName, role, separator, specifyRoleWithoutList, title, type, ...props } = this.props; const keyboardNavKey = makeKeyboardKey(keyboardKey || title || label); const otherProps = omit({ ...props }, [ 'focusLockTabbableChildren', 'focusLockTabbableChildrenProps', 'focusOnLoad', 'id', 'itemIndex', 'onBlur', 'onClick', 'onKeyDown', 'onKeyUp', 'parentKeyDown', 'parentKeyUp', 'parentOnSelect', 'value', ]); const setProps = (cxtProps) => ({ className: 'md-list-item' + `${(cxtProps.type && ` md-list-item--${cxtProps.type}`) || ''}` + `${(cxtProps.active && ` active`) || ''}` + `${(disabled && ` disabled`) || ''}` + `${(isReadOnly && ` md-list-item--read-only`) || ''}` + `${(separator && ` md-list-item--separator`) || ''}` + `${(className && ` ${className}`) || ''}` + `${ (customAnchorNode && customAnchorNode.props.className && ` ${customAnchorNode.props.className}`) || '' }`, id: cxtProps.id, role: cxtProps.role, ...(!customAnchorNode && { ref: (ref) => (this[refName] = ref), ...(link && { href: link }), }), ...(customAnchorNode && customRefProp && { [customRefProp]: (ref) => (this[refName] = ref), }), ...(!isReadOnly && { onClick: (e) => this.handleClick(e, cxtProps.uniqueKey), onKeyDown: (e) => this.handleKeyDown(e, cxtProps.uniqueKey), onKeyUp: (e) => this.handleKeyUp(e, cxtProps.uniqueKey), onBlur: (e) => this.handleBlur(e), tabIndex: !disabled && cxtProps.focus ? 0 : -1, }), 'data-md-event-key': cxtProps.uniqueKey, ...(!cxtProps?.ariaConfig?.disableAriaCurrent && { ...(cxtProps.focus && { 'aria-current': `${cxtProps.focus}` }), }), ...(keyboardNavKey && { 'data-md-keyboard-key': keyboardNavKey }), ...((title || label) && { title: title || label }), ...otherProps, }); const addRefToAnchor = (cxtProps) => { return React.cloneElement( customAnchorNode, setProps(cxtProps), children || customAnchorNode.props.children || label ); }; const createElement = (cxtProps) => { return React.createElement(link ? 'a' : 'div', setProps(cxtProps), children || label); }; return ( <UIDConsumer name={(id) => `md-list-item-${id}`}> {(id) => ( <ListContext.Consumer> {(listContext) => { let contextProps = {}; contextProps.id = this.props.id || id; contextProps.uniqueKey = eventKey || contextProps.id; contextProps.type = type || (listContext && listContext.type); contextProps.focus = focus || (listContext && listContext.focus === contextProps.uniqueKey); contextProps.active = active || (listContext && listContext.active === contextProps.uniqueKey); contextProps.role = specifyRoleWithoutList ? role : (listContext && listContext.role) || role; contextProps.ariaConfig = listContext && listContext.ariaConfig; return customAnchorNode ? addRefToAnchor(contextProps) : createElement(contextProps); }} </ListContext.Consumer> )} </UIDConsumer> ); } } ListItem.propTypes = { /** @prop Active prop to help determine styles | false */ active: PropTypes.bool, /** @prop Children nodes to render inside ListItem | null */ children: PropTypes.node, /** @prop Optional css class string | '' */ className: PropTypes.string, /** @prop Node in which the selection begins | null */ customAnchorNode: PropTypes.element, /** @prop ListItem Custom Prop Name for child with custom Ref | null */ customRefProp: PropTypes.string, /** @prop Disabled attribute for ListItem to determine styles | false */ disabled: PropTypes.bool, /** @prop Unique string used for tracking events among ancestors | '' */ eventKey: PropTypes.string, /** @prop Specifies if ListItem should automatically get focus | false */ focus: PropTypes.bool, /** @prop Locks focus to cycle between all tabbable children | false */ focusLockTabbableChildren: PropTypes.bool, focusLockTabbableChildrenProps: PropTypes.shape({ /** @prop Query for focusLockTabbableChildren | '' */ tabbableChildrenQuery: PropTypes.string.isRequired, /** @prop Indicates whether this ListItem has tabbable children that spawn Popovers | false */ tabbableChildrenHasPopover: PropTypes.bool.isRequired, /** @prop Only for when using tabbableChildrenHasPopover. Need to checkout the EventOverlay for blur purposes | '' */ portalNodeQuery: PropTypes.string.isRequired, /** @prop Used for tabbableChildrenHasPopover to find the DOM element of Popovers */ tabbableChildSpawnedPopoverQuery: PropTypes.string, }), /** @prop Specifies if ListItem should automatically get focus when page loads | false */ focusOnLoad: PropTypes.bool, /** @prop Sets ListItem id | null */ id: PropTypes.string, /** @prop Determines if ListItem is clickable | false */ isReadOnly: PropTypes.bool, /** @prop ListItem index number | null */ itemIndex: PropTypes.number, /** @prop Unique string used for keyboard navigation | '' */ keyboardKey: PropTypes.string, /** @prop ListItem label text | '' */ label: PropTypes.string, /** @prop external link associated input | '' */ link: PropTypes.string, /** @prop Callback function invoked by user changing focus from current ListItem ListItem | null */ onBlur: PropTypes.func, /** @prop Callback function invoked by user tapping on ListItem | null */ onClick: PropTypes.func, /** @prop Callback function invoked by user pressing down on a key | null */ onKeyDown: PropTypes.func, /** @prop Callback function invoked by user pressing up on a key | null */ onKeyUp: PropTypes.func, // Internal Context Use Only parentKeyDown: PropTypes.func, // Internal Context Use Only parentKeyUp: PropTypes.func, // Internal Context Use Only parentOnSelect: PropTypes.func, /** @prop ListItem ref name | 'navLink' */ refName: PropTypes.string, /** @prop Aria role | 'listitem' */ role: PropTypes.string, /** @prop Prop that controls whether to show separator or not | false */ separator: PropTypes.bool, /** @prop Prop that enables assigning role per listitem instead of the default provided by List context */ /** Allows Lists to have listitem with different roles */ specifyRoleWithoutList: PropTypes.bool, /** @prop ListItem Title | '' */ title: PropTypes.string, /** @prop ListItem size | '' */ type: PropTypes.oneOf(['', 'small', 'large', 'xlarge', 'space', 'header', 36, 52, 60]), /** @prop ListItem value for OnSelect value | '' */ value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, PropTypes.object, PropTypes.array, ]), }; ListItem.defaultProps = { active: false, children: null, className: '', customAnchorNode: null, customRefProp: '', disabled: false, eventKey: '', focus: false, focusLockTabbableChildren: false, focusLockTabbableChildrenProps: { tabbableChildrenQuery: '', tabbableChildrenHasPopover: false, portalNodeQuery: '', }, focusOnLoad: false, id: null, itemIndex: null, isReadOnly: false, keyboardKey: '', label: '', link: '', onBlur: null, onClick: null, onKeyDown: null, onKeyUp: null, parentKeyDown: null, parentKeyUp: null, parentOnSelect: null, refName: 'navLink', role: 'listitem', separator: false, specifyRoleWithoutList: false, title: '', type: '', value: '', }; ListItem.displayName = 'ListItem'; export default mapContextToProps(SelectableContext, (context) => context, ListItem);