diff --git a/CHANGELOG.md b/CHANGELOG.md index 54048534ff..94479e015c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### BREAKING CHANGES +- renamed Teams theme menu variables the contains props names as prefixes @mnajdova ([#539](https://github.com/stardust-ui/react/pull/539)) + ### Fixes - Ensure `Popup` properly flips values of `offset` prop in RTL @kuzhelov ([#612](https://github.com/stardust-ui/react/pull/612)) - Fix `List` - items should be selectable @sophieH29 ([#566](https://github.com/stardust-ui/react/pull/566)) @@ -25,6 +28,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `color` prop to `Text` component @Bugaa92 ([#597](https://github.com/stardust-ui/react/pull/597)) - Add `color` prop to `Header` and `HeaderDescription` components @Bugaa92 ([#628](https://github.com/stardust-ui/react/pull/628)) - Add and export compose icons in Teams theme @joheredi ([#639](https://github.com/stardust-ui/react/pull/639)) +- Add `menu` prop to `MenuItem` @mnajdova ([#539](https://github.com/stardust-ui/react/pull/539)) ## [v0.15.0](https://github.com/stardust-ui/react/tree/v0.15.0) (2018-12-17) diff --git a/docs/src/components/CodeSnippet.tsx b/docs/src/components/CodeSnippet.tsx index 357a5c3239..182a4e6c90 100644 --- a/docs/src/components/CodeSnippet.tsx +++ b/docs/src/components/CodeSnippet.tsx @@ -1,4 +1,3 @@ -import * as _ from 'lodash' import * as React from 'react' import formatCode from '../utils/formatCode' diff --git a/docs/src/examples/components/Menu/Types/MenuExampleVerticalWithSubmenu.shorthand.tsx b/docs/src/examples/components/Menu/Types/MenuExampleVerticalWithSubmenu.shorthand.tsx new file mode 100644 index 0000000000..1fc9b1bfde --- /dev/null +++ b/docs/src/examples/components/Menu/Types/MenuExampleVerticalWithSubmenu.shorthand.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { Menu } from '@stardust-ui/react' + +const items = [ + { + key: 'editorials', + content: 'Editorials', + menu: { + items: [ + { key: '1', content: 'item1' }, + { + key: '2', + content: 'item2', + menu: { items: [{ key: '1', content: 'item1' }, { key: '2', content: 'item2' }] }, + }, + ], + }, + }, + { key: 'review', content: 'Reviews' }, + { key: 'events', content: 'Upcoming Events' }, +] + +const MenuExampleVerticalWithSubmenu = () => + +export default MenuExampleVerticalWithSubmenu diff --git a/docs/src/examples/components/Menu/Types/MenuExampleWithSubmenu.shorthand.tsx b/docs/src/examples/components/Menu/Types/MenuExampleWithSubmenu.shorthand.tsx new file mode 100644 index 0000000000..44929b1aa7 --- /dev/null +++ b/docs/src/examples/components/Menu/Types/MenuExampleWithSubmenu.shorthand.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import { Menu } from '@stardust-ui/react' + +const items = [ + { + key: 'editorials', + content: 'Editorials', + menu: { + items: [ + { key: '1', content: 'item1' }, + { + key: '2', + content: 'item2', + menu: { items: [{ key: '1', content: 'item2.1' }, { key: '2', content: 'item2.2' }] }, + }, + { + key: '3', + content: 'item3', + menu: { items: [{ key: '1', content: 'item3.1' }, { key: '2', content: 'item3.2' }] }, + }, + ], + }, + }, + { + key: 'review', + content: 'Reviews', + menu: { + items: [ + { key: '1', content: 'item1' }, + { + key: '2', + content: 'item2', + menu: { items: [{ key: '1', content: 'item2.1' }, { key: '2', content: 'item2.2' }] }, + }, + ], + }, + }, + { key: 'events', content: 'Upcoming Events' }, +] + +const MenuExampleWithSubMenu = () => + +export default MenuExampleWithSubMenu diff --git a/docs/src/examples/components/Menu/Types/index.tsx b/docs/src/examples/components/Menu/Types/index.tsx index 632620ebd3..93239cded7 100644 --- a/docs/src/examples/components/Menu/Types/index.tsx +++ b/docs/src/examples/components/Menu/Types/index.tsx @@ -19,6 +19,12 @@ const Types = () => ( description="A vertical menu displays elements vertically." examplePath="components/Menu/Types/MenuExampleVertical" /> + + ) diff --git a/docs/src/examples/components/Menu/Variations/MenuExampleIconOnlyPrimaryInverted.shorthand.tsx b/docs/src/examples/components/Menu/Variations/MenuExampleIconOnlyPrimaryInverted.shorthand.tsx index 79df9ddee8..19848defe8 100644 --- a/docs/src/examples/components/Menu/Variations/MenuExampleIconOnlyPrimaryInverted.shorthand.tsx +++ b/docs/src/examples/components/Menu/Variations/MenuExampleIconOnlyPrimaryInverted.shorthand.tsx @@ -14,9 +14,9 @@ const MenuExampleIconOnlyPrimaryInverted = () => ( items={items} primary variables={siteVars => ({ - defaultColor: siteVars.gray06, - defaultBackgroundColor: siteVars.brand, - typePrimaryActiveBorderColor: siteVars.white, + color: siteVars.gray06, + backgroundColor: siteVars.brand, + primaryActiveBorderColor: siteVars.white, })} /> ) diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 0aa98d68aa..85bebbdce1 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import { AutoControlledComponent, childrenExist, + createShorthandFactory, customPropTypes, UIComponentProps, ChildrenComponentProps, @@ -60,12 +61,19 @@ export interface MenuProps extends UIComponentProps, ChildrenComponentProps { /** A vertical menu displays elements vertically. */ vertical?: boolean + + /** Indicates whether the menu is submenu. */ + submenu?: boolean +} + +export interface MenuState { + activeIndex?: number | string } /** * A menu displays grouped navigation actions. */ -class Menu extends AutoControlledComponent, any> { +class Menu extends AutoControlledComponent, MenuState> { static displayName = 'Menu' static className = 'ui-menu' @@ -88,6 +96,7 @@ class Menu extends AutoControlledComponent, any> { secondary: customPropTypes.every([customPropTypes.disallow(['primary']), PropTypes.bool]), underlined: PropTypes.bool, vertical: PropTypes.bool, + submenu: PropTypes.bool, } static defaultProps = { @@ -107,6 +116,15 @@ class Menu extends AutoControlledComponent, any> { _.invoke(predefinedProps, 'onClick', e, itemProps) }, + onActiveChanged: (e, props) => { + const { index, active } = props + if (active) { + this.trySetState({ activeIndex: index }) + } else if (this.state.activeIndex === index) { + this.trySetState({ activeIndex: null }) + } + _.invoke(predefinedProps, 'onActiveChanged', e, props) + }, }) renderItems = (variables: ComponentVariablesObject) => { @@ -119,11 +137,14 @@ class Menu extends AutoControlledComponent, any> { secondary, underlined, vertical, + submenu, } = this.props const { activeIndex } = this.state - return _.map(items, (item, index) => - MenuItem.create(item, { + return _.map(items, (item, index) => { + const active = + (typeof activeIndex === 'string' ? parseInt(activeIndex, 10) : activeIndex) === index + return MenuItem.create(item, { defaultProps: { iconOnly, pills, @@ -134,11 +155,12 @@ class Menu extends AutoControlledComponent, any> { variables, vertical, index, - active: parseInt(activeIndex, 10) === index, + active, + inSubmenu: submenu, }, overrideProps: this.handleItemOverrides, - }), - ) + }) + }) } renderComponent({ ElementType, classes, accessibility, variables, rest }) { @@ -151,4 +173,6 @@ class Menu extends AutoControlledComponent, any> { } } +Menu.create = createShorthandFactory(Menu, 'items') + export default Menu diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx index 7b7837c0a9..143734a42c 100644 --- a/src/components/Menu/MenuItem.tsx +++ b/src/components/Menu/MenuItem.tsx @@ -4,21 +4,26 @@ import * as PropTypes from 'prop-types' import * as React from 'react' import { + AutoControlledComponent, childrenExist, createShorthandFactory, customPropTypes, - UIComponent, + doesNodeContainClick, UIComponentProps, ChildrenComponentProps, ContentComponentProps, commonPropTypes, isFromKeyboard, + EventStack, } from '../../lib' import Icon from '../Icon/Icon' +import Menu from '../Menu/Menu' import Slot from '../Slot/Slot' -import { menuItemBehavior } from '../../lib/accessibility' +import { menuItemBehavior, submenuBehavior } from '../../lib/accessibility' import { Accessibility, AccessibilityActionHandlers } from '../../lib/accessibility/types' import { ComponentEventHandler, Extendable, ShorthandValue } from '../../../types/utils' +import { focusAsync } from '../../lib/accessibility/FocusZone' +import Ref from '../Ref/Ref' export interface MenuItemProps extends UIComponentProps, @@ -55,6 +60,14 @@ export interface MenuItemProps */ onClick?: ComponentEventHandler + /** + * Called on key down pressed. + * + * @param {SyntheticEvent} event - React's original SyntheticEvent. + * @param {object} data - All props. + */ + onKeyDown?: ComponentEventHandler + /** A menu can adjust its appearance to de-emphasize its contents. */ pills?: boolean @@ -78,16 +91,32 @@ export interface MenuItemProps /** Shorthand for the wrapper component. */ wrapper?: ShorthandValue + + /** Shorthand for the submenu. */ + menu?: ShorthandValue + + /** Indicates if the menu inside the item is open. */ + menuOpen?: boolean + + /** Default menu open */ + defaultMenuOpen?: boolean + + /** Callback for setting the current menu item as active element in the menu. */ + onActiveChanged?: ComponentEventHandler + + /** Indicates whether the menu item is part of submenu. */ + inSubmenu?: boolean } export interface MenuItemState { isFromKeyboard: boolean + menuOpen: boolean } /** * A menu item is an actionable navigation item within a menu. */ -class MenuItem extends UIComponent, MenuItemState> { +class MenuItem extends AutoControlledComponent, MenuItemState> { static displayName = 'MenuItem' static className = 'ui-menu__item' @@ -110,6 +139,11 @@ class MenuItem extends UIComponent, MenuItemState> { underlined: PropTypes.bool, vertical: PropTypes.bool, wrapper: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), + menu: customPropTypes.itemShorthand, + menuOpen: PropTypes.bool, + defaultMenuOpen: PropTypes.bool, + onActiveChanged: PropTypes.func, + inSubmenu: PropTypes.bool, } static defaultProps = { @@ -118,32 +152,65 @@ class MenuItem extends UIComponent, MenuItemState> { wrapper: { as: 'li' }, } - state = { - isFromKeyboard: false, + static autoControlledProps = ['menuOpen'] + + private outsideClickSubscription = EventStack.noSubscription + + private menuRef = React.createRef() + private itemRef = React.createRef() + + public componentDidMount() { + this.updateOutsideClickSubscription() + } + + public componentDidUpdate() { + this.updateOutsideClickSubscription() + } + + public componentWillUnmount() { + this.outsideClickSubscription.unsubscribe() } - renderComponent({ ElementType, classes, accessibility, rest }) { - const { children, content, icon, wrapper } = this.props + renderComponent({ ElementType, classes, accessibility, rest, styles }) { + const { children, content, icon, wrapper, menu, primary, secondary, active } = this.props + + const { menuOpen } = this.state const menuItemInner = childrenExist(children) ? ( children ) : ( - - {icon && - Icon.create(this.props.icon, { - defaultProps: { xSpacing: !!content ? 'after' : 'none' }, - })} - {content} - + + + {icon && + Icon.create(this.props.icon, { + defaultProps: { xSpacing: !!content ? 'after' : 'none' }, + })} + {content} + + ) + const maybeSubmenu = + menu && active && menuOpen ? ( + + {Menu.create(menu, { + defaultProps: { + accessibility: submenuBehavior, + vertical: true, + primary, + secondary, + styles: styles.menu, + submenu: true, + }, + })} + + ) : null if (wrapper) { return Slot.create(wrapper, { @@ -153,18 +220,70 @@ class MenuItem extends UIComponent, MenuItemState> { ...accessibility.keyHandlers.root, }, overrideProps: () => ({ - children: menuItemInner, + children: ( + <> + {menuItemInner} + {maybeSubmenu} + + ), + onClick: this.handleClick, + onBlur: this.handleWrapperBlur, }), }) } return menuItemInner } + private handleWrapperBlur = e => { + if (!this.props.inSubmenu && !e.currentTarget.contains(e.relatedTarget)) { + this.setState({ menuOpen: false }) + } + } + protected actionHandlers: AccessibilityActionHandlers = { performClick: event => this.handleClick(event), + openMenu: event => this.openMenu(event), + closeAllMenus: event => this.closeAllMenus(event, false), + closeAllMenusAndFocusNextParentItem: event => this.closeAllMenus(event, true), + closeMenu: event => this.closeMenu(event), + } + + private updateOutsideClickSubscription() { + this.outsideClickSubscription.unsubscribe() + + if (this.props.menu && this.state.menuOpen) { + setTimeout(() => { + this.outsideClickSubscription = EventStack.subscribe('click', this.outsideClickHandler) + }) + } + } + + private outsideClickHandler = e => { + if (!this.state.menuOpen) return + if ( + !doesNodeContainClick(this.itemRef.current, e) && + !doesNodeContainClick(this.menuRef.current, e) + ) { + this.trySetState({ menuOpen: false }) + } + } + + private performClick = e => { + const { active, menu } = this.props + if (menu) { + if (doesNodeContainClick(this.menuRef.current, e)) { + // submenu was clicked => close it and propagate + this.setState({ menuOpen: false }, () => focusAsync(this.itemRef.current)) + } else { + // the menuItem element was clicked => toggle the open/close and stop propagation + this.trySetState({ menuOpen: active ? !this.state.menuOpen : true }) + e.stopPropagation() + } + } } private handleClick = e => { + this.performClick(e) _.invoke(this.props, 'onClick', e, this.props) } @@ -179,6 +298,45 @@ class MenuItem extends UIComponent, MenuItemState> { _.invoke(this.props, 'onFocus', e, this.props) } + + private closeAllMenus = (e, focusNextParent: boolean) => { + const { menu, inSubmenu } = this.props + const { menuOpen } = this.state + if (menu && menuOpen) { + this.setState({ menuOpen: false }, () => { + if (!inSubmenu && (!focusNextParent || this.props.vertical)) { + focusAsync(this.itemRef.current) + } + }) + } + } + + private closeMenu = e => { + const { menu, inSubmenu } = this.props + const { menuOpen } = this.state + const shouldStopPropagation = inSubmenu || this.props.vertical + if (menu && menuOpen) { + this.setState({ menuOpen: false }, () => { + if (shouldStopPropagation) { + focusAsync(this.itemRef.current) + } + }) + if (shouldStopPropagation) { + e.stopPropagation() + } + } + } + + private openMenu = e => { + const { menu } = this.props + const { menuOpen } = this.state + if (menu && !menuOpen) { + this.setState({ menuOpen: true }) + _.invoke(this.props, 'onActiveChanged', e, { ...this.props, active: true }) + e.stopPropagation() + e.preventDefault() + } + } } MenuItem.create = createShorthandFactory(MenuItem, 'content') diff --git a/src/index.ts b/src/index.ts index 2edc275517..b324c0fc66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,7 +74,7 @@ export { default as Layout, LayoutPropsWithDefaults, LayoutProps } from './compo export { default as List, ListProps } from './components/List/List' export { default as ListItem, ListItemProps } from './components/List/ListItem' -export { default as Menu, MenuProps } from './components/Menu/Menu' +export { default as Menu, MenuProps, MenuState } from './components/Menu/Menu' export { default as MenuItem, MenuItemState, MenuItemProps } from './components/Menu/MenuItem' export { default as Popup, PopupState, PopupProps } from './components/Popup/Popup' diff --git a/src/lib/accessibility/Behaviors/Menu/menuBehavior.ts b/src/lib/accessibility/Behaviors/Menu/menuBehavior.ts index 377936f75f..bba1918654 100644 --- a/src/lib/accessibility/Behaviors/Menu/menuBehavior.ts +++ b/src/lib/accessibility/Behaviors/Menu/menuBehavior.ts @@ -1,4 +1,5 @@ import { Accessibility, FocusZoneMode } from '../../types' +import { FocusZoneDirection } from '../../FocusZone' /** * @description @@ -21,6 +22,7 @@ const menuBehavior: Accessibility = (props: any) => ({ isCircularNavigation: true, preventDefaultWhenHandled: true, shouldFocusFirstElementWhenReceivedFocus: true, + direction: props.vertical ? FocusZoneDirection.vertical : FocusZoneDirection.horizontal, }, }, }) diff --git a/src/lib/accessibility/Behaviors/Menu/menuItemBehavior.ts b/src/lib/accessibility/Behaviors/Menu/menuItemBehavior.ts index f00c978834..730b783f9c 100644 --- a/src/lib/accessibility/Behaviors/Menu/menuItemBehavior.ts +++ b/src/lib/accessibility/Behaviors/Menu/menuItemBehavior.ts @@ -34,10 +34,24 @@ const menuItemBehavior: Accessibility = (props: any) => ({ handledProps: ['aria-label', 'aria-labelledby', 'aria-describedby'], keyActions: { - anchor: { + root: { performClick: { keyCombinations: [{ keyCode: keyboardKey.Enter }, { keyCode: keyboardKey.Spacebar }], }, + closeAllMenus: { + keyCombinations: [{ keyCode: keyboardKey.Escape }], + }, + closeAllMenusAndFocusNextParentItem: { + keyCombinations: [{ keyCode: keyboardKey.ArrowRight }], + }, + closeMenu: { + keyCombinations: [{ keyCode: keyboardKey.ArrowLeft }], + }, + openMenu: { + keyCombinations: [ + { keyCode: props.vertical ? keyboardKey.ArrowRight : keyboardKey.ArrowDown }, + ], + }, }, }, }) diff --git a/src/lib/accessibility/Behaviors/Menu/submenuBehavior.ts b/src/lib/accessibility/Behaviors/Menu/submenuBehavior.ts new file mode 100644 index 0000000000..2ef65e9e3e --- /dev/null +++ b/src/lib/accessibility/Behaviors/Menu/submenuBehavior.ts @@ -0,0 +1,31 @@ +import { Accessibility, FocusZoneMode } from '../../types' +import { FocusZoneDirection } from '../../FocusZone' + +/** + * @description + * The 'menu' role is used to identify an element that creates a list of common actions or functions that a user can invoke. + * + * @specification + * Adds role='menu'. + * Embeds FocusZone into component allowing circular arrow key navigation through the children of the component. + */ + +const submenuBehavior: Accessibility = (props: any) => ({ + attributes: { + root: { + role: 'menu', + }, + }, + focusZone: { + mode: FocusZoneMode.Embed, + props: { + isCircularNavigation: true, + preventDefaultWhenHandled: true, + shouldFocusFirstElementWhenReceivedFocus: true, + shouldFocusOnMount: true, + direction: FocusZoneDirection.vertical, + }, + }, +}) + +export default submenuBehavior diff --git a/src/lib/accessibility/index.ts b/src/lib/accessibility/index.ts index f795558a7d..82bdf084d2 100644 --- a/src/lib/accessibility/index.ts +++ b/src/lib/accessibility/index.ts @@ -4,6 +4,7 @@ export { default as toggleButtonBehavior } from './Behaviors/Button/toggleButton export { default as imageBehavior } from './Behaviors/Image/imageBehavior' export { default as menuBehavior } from './Behaviors/Menu/menuBehavior' export { default as menuItemBehavior } from './Behaviors/Menu/menuItemBehavior' +export { default as submenuBehavior } from './Behaviors/Menu/submenuBehavior' export { default as basicListBehavior } from './Behaviors/List/listBehavior' export { default as basicListItemBehavior } from './Behaviors/List/basicListItemBehavior' export { default as listBehavior } from './Behaviors/List/listBehavior' diff --git a/src/themes/teams/components/Menu/menuItemStyles.ts b/src/themes/teams/components/Menu/menuItemStyles.ts index 4bc41f493c..36ef09fbf5 100644 --- a/src/themes/teams/components/Menu/menuItemStyles.ts +++ b/src/themes/teams/components/Menu/menuItemStyles.ts @@ -20,10 +20,10 @@ const getActionStyles = ({ variables: MenuVariables color: string }): ICSSInJSStyle => - (underlined && !isFromKeyboard) || iconOnly + underlined || iconOnly ? { color, - background: v.defaultBackgroundColor, + background: v.backgroundColor, } : primary ? { @@ -32,9 +32,38 @@ const getActionStyles = ({ } : { color, - background: v.defaultActiveBackgroundColor, + background: v.activeBackgroundColor, } +const getFocusedStyles = ({ + props, + variables: v, + color, +}: { + props: MenuItemPropsAndState + variables: MenuVariables + color: string +}): ICSSInJSStyle => { + const { primary, underlined, iconOnly, isFromKeyboard, active } = props + if (active && !underlined) return {} + return { + ...((underlined && !isFromKeyboard) || iconOnly + ? { + color, + background: v.backgroundColor, + } + : primary + ? { + color: v.primaryFocusedColor, + background: v.primaryFocusedBackgroundColor, + } + : { + color, + background: v.focusedBackgroundColor, + }), + } +} + const itemSeparator: ComponentSlotStyleFunction = ({ props, variables: v, @@ -52,16 +81,8 @@ const itemSeparator: ComponentSlotStyleFunction ({ + zIndex: '1000', + position: 'absolute', + top: vertical ? '0' : '100%', + left: vertical ? '100%' : '0', + }), } export default menuItemStyles diff --git a/src/themes/teams/components/Menu/menuStyles.ts b/src/themes/teams/components/Menu/menuStyles.ts index fc6cc2d2c0..d3fc40d934 100644 --- a/src/themes/teams/components/Menu/menuStyles.ts +++ b/src/themes/teams/components/Menu/menuStyles.ts @@ -1,6 +1,9 @@ import { pxToRem } from '../../utils' import { ComponentSlotStylesInput, ICSSInJSStyle } from '../../../types' -import { MenuProps } from '../../../../components/Menu/Menu' +import { MenuProps, MenuState } from '../../../../components/Menu/Menu' +import { MenuVariables } from './menuVariables' + +type MenuPropsAndState = MenuProps & MenuState const solidBorder = (color: string) => ({ border: `1px solid ${color}`, @@ -24,12 +27,11 @@ export default { !iconOnly && !(pointing && vertical) && !underlined && { - ...solidBorder(variables.defaultBorderColor), + ...solidBorder(variables.borderColor), ...(primary && { ...solidBorder(variables.primaryBorderColor), }), borderRadius: pxToRem(4), - overflow: 'hidden', }), ...(underlined && { borderBottom: `2px solid ${variables.primaryUnderlinedBorderColor}`, @@ -40,4 +42,4 @@ export default { listStyleType: 'none', } }, -} as ComponentSlotStylesInput +} as ComponentSlotStylesInput diff --git a/src/themes/teams/components/Menu/menuVariables.ts b/src/themes/teams/components/Menu/menuVariables.ts index 9282b21e85..3558f204b1 100644 --- a/src/themes/teams/components/Menu/menuVariables.ts +++ b/src/themes/teams/components/Menu/menuVariables.ts @@ -1,43 +1,57 @@ import { pxToRem } from '../../utils' export interface MenuVariables { - defaultColor: string - defaultBackgroundColor: string + color: string + backgroundColor: string - defaultActiveColor: string - defaultActiveBackgroundColor: string - defaultBorderColor: string + activeColor: string + activeBackgroundColor: string + focusedBackgroundColor: string + borderColor: string primaryActiveColor: string primaryActiveBackgroundColor: string primaryActiveBorderColor: string + primaryFocusedColor: string + primaryFocusedBackgroundColor: string + primaryBorderColor: string primaryHoverBorderColor: string primaryUnderlinedBorderColor: string circularRadius: string lineHeightBase: string + + submenuIndicatorContent: string + submenuIndicatorRotationAngle: number } export default (siteVars: any): MenuVariables => { return { - defaultColor: siteVars.gray02, - defaultBackgroundColor: 'transparent', + color: siteVars.gray02, + backgroundColor: siteVars.white, - defaultActiveColor: siteVars.black, - defaultActiveBackgroundColor: siteVars.gray10, - defaultBorderColor: siteVars.gray08, + activeColor: siteVars.black, + activeBackgroundColor: siteVars.gray10, + focusedBackgroundColor: siteVars.gray14, + borderColor: siteVars.gray08, primaryActiveColor: siteVars.white, primaryActiveBackgroundColor: siteVars.brand08, primaryActiveBorderColor: siteVars.brand, + primaryFocusedColor: siteVars.white, + primaryFocusedBackgroundColor: siteVars.brand12, + primaryBorderColor: siteVars.brand08, primaryHoverBorderColor: siteVars.gray08, primaryUnderlinedBorderColor: siteVars.gray08, circularRadius: pxToRem(999), lineHeightBase: siteVars.lineHeightBase, + + submenuIndicatorContent: '">"', + submenuIndicatorRotationAngle: 90, } } diff --git a/test/specs/behaviors/behavior-test.tsx b/test/specs/behaviors/behavior-test.tsx index dd8b411b77..c6ea5bf2c8 100644 --- a/test/specs/behaviors/behavior-test.tsx +++ b/test/specs/behaviors/behavior-test.tsx @@ -13,6 +13,7 @@ import { inputBehavior, menuBehavior, menuItemBehavior, + submenuBehavior, popupBehavior, popupFocusTrapBehavior, dialogBehavior, @@ -44,6 +45,7 @@ testHelper.addBehavior('inputBehavior', inputBehavior) testHelper.addBehavior('imageBehavior', imageBehavior) testHelper.addBehavior('menuBehavior', menuBehavior) testHelper.addBehavior('menuItemBehavior', menuItemBehavior) +testHelper.addBehavior('submenuBehavior', submenuBehavior) testHelper.addBehavior('popupBehavior', popupBehavior) testHelper.addBehavior('popupFocusTrapBehavior', popupFocusTrapBehavior) testHelper.addBehavior('radioGroupBehavior', radioGroupBehavior) diff --git a/test/specs/commonTests/isConformant.tsx b/test/specs/commonTests/isConformant.tsx index 5836b629b2..a024a4391e 100644 --- a/test/specs/commonTests/isConformant.tsx +++ b/test/specs/commonTests/isConformant.tsx @@ -19,6 +19,7 @@ import { FOCUSZONE_WRAP_ATTRIBUTE } from 'src/lib/accessibility/FocusZone/focusU export interface Conformant { eventTargets?: object + nestingLevel?: number requiredProps?: object exportedAtTopLevel?: boolean rendersPortal?: boolean @@ -39,6 +40,7 @@ export default (Component, options: Conformant = {}) => { const { eventTargets = {}, exportedAtTopLevel = true, + nestingLevel = 0, requiredProps = {}, rendersPortal = false, usesWrapperSlot = false, @@ -51,11 +53,18 @@ export default (Component, options: Conformant = {}) => { const getComponent = (wrapper: ReactWrapper) => { // FelaTheme wrapper and the component itself: let component = wrapper - .childAt(0) - .childAt(0) - .childAt(0) + + /** + * The wrapper is mounted with Provider, so in total there are three HOC components + * that we want to get rid of: ThemeProvider, the actual Component and FelaTheme, + * in order to be able to get to the actual rendered result of the component we are testing + */ + _.times(nestingLevel + 3, () => { + component = component.childAt(0) + }) + if (component.type() === FocusZone) { - // `component` is + // another HOC component is added: FocuZone component = component.childAt(0) // skip through if (component.prop(FOCUSZONE_WRAP_ATTRIBUTE)) { component = component.childAt(0) // skip the additional wrap
of the FocusZone @@ -63,10 +72,14 @@ export default (Component, options: Conformant = {}) => { } if (usesWrapperSlot) { - component = component - .childAt(0) - .childAt(0) - .childAt(0) + /** + * If there is a wrapper slot, then again, we need to get rid of all three HOC components: + * ThemeProvider, Wrapper (Slot), and FelaTheme in order to be able to get to the actual + * rendered result of the component we are testing + */ + _.times(3, () => { + component = component.childAt(0) + }) } return component diff --git a/test/specs/components/Menu/MenuItem-test.tsx b/test/specs/components/Menu/MenuItem-test.tsx index d85082d9e4..c24ea2547c 100644 --- a/test/specs/components/Menu/MenuItem-test.tsx +++ b/test/specs/components/Menu/MenuItem-test.tsx @@ -8,8 +8,10 @@ import { toolbarButtonBehavior, tabBehavior } from '../../../../src/lib/accessib describe('MenuItem', () => { isConformant(MenuItem, { eventTargets: { - onClick: 'a', + onClick: '.ui-menu__item__wrapper', }, + // The ElementType is wrapped with Ref, which is adding two HOC in total + nestingLevel: 2, usesWrapperSlot: true, }) @@ -19,7 +21,14 @@ describe('MenuItem', () => { .hostNodes() expect(menuItem.is('li')).toBe(true) - expect(menuItem.childAt(0).is('a')).toBe(true) + // The ElementType is wrapped with Ref, which is adding two HOC in total, that's why we need the three childAt(0) usages + expect( + menuItem + .childAt(0) + .childAt(0) + .childAt(0) + .is('a'), + ).toBe(true) expect(menuItem.text()).toBe('Home') })