Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

feat(Menu): Make Submenu open state controlled #1125

Merged
merged 20 commits into from
Apr 1, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react'
import { Menu } from '@stardust-ui/react'

class MenuWithSubmenuControlledExample extends React.Component {
state = { editorialsMenuOpen: false }

handleMenuOpenChange = (e, { menuOpen }) => {
this.setState({ editorialsMenuOpen: menuOpen })
}

renderItems = () => {
return [
{
key: 'editorials',
content: 'Editorials',
menuOpen: this.state.editorialsMenuOpen,
onMenuOpenChange: this.handleMenuOpenChange,
menu: {
items: [
{ key: '1', content: 'item1' },
{
key: '2',
content: 'item2',
menu: [{ key: '1', content: 'item2.1' }, { key: '2', content: 'item2.2' }],
},
{
key: '3',
content: 'item3',
menu: [{ key: '1', content: 'item3.1' }, { key: '2', content: 'item3.2' }],
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to make it more compact:

menu: [
  { key: '1', content: 'item1' },
  { key: '2', content: 'item2' },
  { key: '3', content: 'item3' },
],

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it will become more compact, do we need renderItems() at all?

],
},
},
{ key: 'events', content: 'Upcoming Events' },
]
}

render() {
return (
<>
<span>{`Editorials menu item requested to change its submenu open state to "${
this.state.editorialsMenuOpen
}".`}</span>
<Menu defaultActiveIndex={0} items={this.renderItems()} />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks unreadable 🤔

What about something like this:

<Header as="h5" content="Current state:" />
<pre>{JSON.stringify(this.state, null, 2)}</pre>
<Divider />
<Menu defaultActiveIndex={0} items={this.renderItems()} />
  

</>
)
}
}

export default MenuWithSubmenuControlledExample
5 changes: 5 additions & 0 deletions docs/src/examples/components/Menu/Usages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const Usages = () => (
description="A menu can have submenus."
examplePath="components/Menu/Usages/MenuExampleWithSubmenu"
/>
<ComponentExample
title="Menu with submenus controlled"
description="When Submenu in MenuItem is controlled, then its 'menuOpen' prop value could be changed either by parent component, or by user actions (e.g. key press) - thus it is necessary to handle 'onMenuOpenChange' event."
examplePath="components/Menu/Usages/MenuExampleWithSubmenuControlled"
/>
</ExampleSection>
)

Expand Down
31 changes: 24 additions & 7 deletions packages/react/src/components/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ export interface MenuItemProps

/** Shorthand for the submenu indicator. */
indicator?: ShorthandValue

/**
* Event for request to change 'open' value.
* @param {SyntheticEvent} event - React's original SyntheticEvent.
* @param {object} data - All props and proposed value.
*/
onMenuOpenChange?: ComponentEventHandler<MenuItemProps>
}

export interface MenuItemState {
Expand Down Expand Up @@ -165,6 +172,7 @@ class MenuItem extends AutoControlledComponent<ReactProps<MenuItemProps>, MenuIt
onActiveChanged: PropTypes.func,
inSubmenu: PropTypes.bool,
indicator: customPropTypes.itemShorthand,
onMenuOpenChange: PropTypes.func,
}

static defaultProps = {
Expand Down Expand Up @@ -270,7 +278,7 @@ class MenuItem extends AutoControlledComponent<ReactProps<MenuItemProps>, MenuIt

private handleWrapperBlur = e => {
if (!this.props.inSubmenu && !e.currentTarget.contains(e.relatedTarget)) {
this.setState({ menuOpen: false })
this.trySetMenuOpen(false, e)
}
}

Expand All @@ -288,19 +296,20 @@ class MenuItem extends AutoControlledComponent<ReactProps<MenuItemProps>, MenuIt
!doesNodeContainClick(this.itemRef.current, e) &&
!doesNodeContainClick(this.menuRef.current, e)
) {
this.trySetState({ menuOpen: false })
this.trySetMenuOpen(false, e)
}
}

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))
this.trySetMenuOpen(false, e, () => 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 })
this.trySetMenuOpen(active ? !this.state.menuOpen : true, e)
e.stopPropagation()
e.preventDefault()
}
Expand Down Expand Up @@ -335,7 +344,7 @@ class MenuItem extends AutoControlledComponent<ReactProps<MenuItemProps>, MenuIt
const { menu, inSubmenu } = this.props
const { menuOpen } = this.state
if (menu && menuOpen) {
this.setState({ menuOpen: false }, () => {
this.trySetMenuOpen(false, e, () => {
if (!inSubmenu && (!focusNextParent || this.props.vertical)) {
focusAsync(this.itemRef.current)
}
Expand All @@ -348,7 +357,7 @@ class MenuItem extends AutoControlledComponent<ReactProps<MenuItemProps>, MenuIt
const { menuOpen } = this.state
const shouldStopPropagation = inSubmenu || this.props.vertical
if (menu && menuOpen) {
this.setState({ menuOpen: false }, () => {
this.trySetMenuOpen(false, e, () => {
if (shouldStopPropagation) {
focusAsync(this.itemRef.current)
}
Expand All @@ -363,12 +372,20 @@ class MenuItem extends AutoControlledComponent<ReactProps<MenuItemProps>, MenuIt
const { menu } = this.props
const { menuOpen } = this.state
if (menu && !menuOpen) {
this.setState({ menuOpen: true })
this.trySetMenuOpen(true, e)
_.invoke(this.props, 'onActiveChanged', e, { ...this.props, active: true })
e.stopPropagation()
e.preventDefault()
}
}

private trySetMenuOpen(newValue: boolean, eventArgs: any, onStateChanged?: any) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private trySetMenuOpen(newValue: boolean, eventArgs: any, onStateChanged?: any) {
private trySetMenuOpen(e: React.SyntethicEvent, newValue: boolean, onStateChanged?: any) {

nit: Can we place e param on the first place as in other methods?

Copy link
Contributor Author

@sophieH29 sophieH29 Apr 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi, the order is the same as in Popup for trySetOpen method.
And the other thing, if you would want to change state without event, you would have to call
trySetMenuOpen(null, newValue)
instead of trySetMenuOpen(newValue). I would leave an order as it is as I think the newValue param has biggest priority than event object

this.trySetState({ menuOpen: newValue }, null, onStateChanged)
_.invoke(this.props, 'onMenuOpenChange', eventArgs, {
...this.props,
...{ menuOpen: newValue },
sophieH29 marked this conversation as resolved.
Show resolved Hide resolved
})
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this method, but I don't have a better option now...

}

MenuItem.create = createShorthandFactory({ Component: MenuItem, mappedProp: 'content' })
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/lib/AutoControlledComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,9 @@ export default class AutoControlledComponent<P = {}, S = {}> extends UIComponent
* Second argument is a state object that is always passed to setState.
* @param {object} maybeState State that corresponds to controlled props.
* @param {object} [state] Actual state, useful when you also need to setState.
* @param {object} onStateChanged Callback which is called after setState applied.
*/
trySetState = (maybeState, state?) => {
trySetState = (maybeState, state?, onStateChanged?: () => void) => {
sophieH29 marked this conversation as resolved.
Show resolved Hide resolved
const { autoControlledProps } = this.constructor as any
if (process.env.NODE_ENV !== 'production') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check should be removed, otherwise we will get incorrect warnings

const { name } = this.constructor
Expand Down Expand Up @@ -218,6 +219,6 @@ export default class AutoControlledComponent<P = {}, S = {}> extends UIComponent

if (state) newState = { ...newState, ...state }

if (Object.keys(newState).length > 0) this.setState(newState)
if (Object.keys(newState).length > 0) this.setState(newState, onStateChanged)
}
}