Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Add `color`, `inverted` and `renderContent` props and `content` slot to `Segment` component @Bugaa92 ([#389](https://github.com/stardust-ui/react/pull/389))
- Add focus trap behavior to `Popup` @kuzhelov ([#457](https://github.com/stardust-ui/react/pull/457))
- Export `Ref` component and add `handleRef` util @layershifter ([#459](https://github.com/stardust-ui/react/pull/459))
- Add `wrapper` slot to `MenuItem` @miroslavstastny ([#323](https://github.com/stardust-ui/react/pull/323))

### Documentation
- Add all missing component descriptions and improve those existing @levithomason ([#400](https://github.com/stardust-ui/react/pull/400))
Expand Down
71 changes: 47 additions & 24 deletions src/components/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as React from 'react'

import { childrenExist, createShorthandFactory, customPropTypes, UIComponent } from '../../lib'
import Icon from '../Icon/Icon'
import Slot from '../Slot/Slot'
import { menuItemBehavior } from '../../lib/accessibility'
import { Accessibility, AccessibilityActionHandlers } from '../../lib/accessibility/types'
import IsFromKeyboard from '../../lib/isFromKeyboard'
Expand Down Expand Up @@ -81,6 +82,15 @@ export interface MenuItemProps
*/
renderIcon?: ShorthandRenderFunction

/**
* A custom render function the wrapper slot.
*
* @param {React.ReactType} Component - The computed component for this slot.
* @param {object} props - The computed props for this slot.
* @param {ReactNode|ReactNodeArray} children - The computed children for this slot.
*/
renderWrapper?: ShorthandRenderFunction

/** The menu item can have secondary type. */
secondary?: boolean

Expand All @@ -89,6 +99,9 @@ export interface MenuItemProps

/** A vertical menu displays elements vertically. */
vertical?: boolean

/** Shorthand for the wrapper component. */
wrapper?: ShorthandValue
}

export interface MenuItemState {
Expand Down Expand Up @@ -123,46 +136,56 @@ class MenuItem extends UIComponent<Extendable<MenuItemProps>, MenuItemState> {
underlined: PropTypes.bool,
vertical: PropTypes.bool,
renderIcon: PropTypes.func,
wrapper: PropTypes.oneOfType([PropTypes.node, PropTypes.object]),
renderWrapper: PropTypes.func,
}

static defaultProps = {
as: 'li',
as: 'a',
Copy link
Collaborator

Choose a reason for hiding this comment

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

when we discussed changes for Input component we had a consensus that as and styles props always go to the root element; we might want the same for MenuItem; let's see what others say @levithomason @kuzhelov

Copy link
Member

Choose a reason for hiding this comment

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

IIRC there is an accessibility requirement that the click listener needs to be on the anchor tag. The primary use case for as on the menu item is to allow adding router functionality. This means the router's navigation handler needs to be applied to the anchor:

import { NavLink } from 'react-router'

<Menu items={[{ as: NavLink, to: '/', content: 'Home' }]} />

If the as prop goes to the root, it will still work as the event will bubble from the anchor up to the li, however, it would not be the correct markup and behavior for the accessibility requirement.

accessibility: menuItemBehavior as Accessibility,
wrapper: { as: 'li' },
}

state = IsFromKeyboard.initial

renderComponent({ ElementType, classes, accessibility, rest }) {
const { children, content, icon, renderIcon } = this.props
const { children, content, icon, renderIcon, renderWrapper, wrapper } = this.props

return (
const menuItemInner = childrenExist(children) ? (
children
) : (
<ElementType
className={classes.root}
Copy link
Collaborator

Choose a reason for hiding this comment

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

when we discussed changes for Input component we had a consensus that styles prop always goes to the root element; we might want the same for MenuItem; this will break that convention when wrapper prop is defined; let's see what others say @levithomason @kuzhelov

{...accessibility.attributes.root}
{...accessibility.keyHandlers.root}
onClick={this.handleClick}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
{...accessibility.attributes.anchor}
{...accessibility.keyHandlers.anchor}
{...rest}
>
{childrenExist(children) ? (
children
) : (
<a
className={cx('ui-menu__item__anchor', classes.anchor)}
onClick={this.handleClick}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
{...accessibility.attributes.anchor}
{...accessibility.keyHandlers.anchor}
>
{icon &&
Icon.create(this.props.icon, {
defaultProps: { xSpacing: !!content ? 'after' : 'none' },
render: renderIcon,
})}
{content}
</a>
)}
{icon &&
Icon.create(this.props.icon, {
defaultProps: { xSpacing: !!content ? 'after' : 'none' },
render: renderIcon,
})}
{content}
</ElementType>
)

if (wrapper) {
return Slot.create(wrapper, {
defaultProps: {
className: cx('ui-menu__item__wrapper', classes.wrapper),
...accessibility.attributes.root,
...accessibility.keyHandlers.root,
},
render: renderWrapper,
overrideProps: () => ({
children: menuItemInner,
Copy link
Collaborator

Choose a reason for hiding this comment

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

very nice usage of overrideProps for children in the wrapper; basically we achieve not allowing the user to override children through wrapper prop 👍
I'll create a PR to do the same for Input component

Copy link
Member

Choose a reason for hiding this comment

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

In this case we can use overrideProps as object

}),
})
}
return menuItemInner
}

protected actionHandlers: AccessibilityActionHandlers = {
Expand Down
4 changes: 2 additions & 2 deletions src/themes/teams/components/Menu/menuItemStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const pointingBeak: ComponentSlotStyleFunction<MenuItemPropsAndState, MenuVariab
}

const menuItemStyles: ComponentSlotStylesInput<MenuItemPropsAndState, MenuVariables> = {
root: ({ props, variables: v, theme }): ICSSInJSStyle => {
wrapper: ({ props, variables: v, theme }): ICSSInJSStyle => {
const { active, isFromKeyboard, pills, pointing, secondary, underlined, vertical } = props

return {
Expand Down Expand Up @@ -186,7 +186,7 @@ const menuItemStyles: ComponentSlotStylesInput<MenuItemPropsAndState, MenuVariab
}
},

anchor: ({ props, variables: v }): ICSSInJSStyle => {
root: ({ props, variables: v }): ICSSInJSStyle => {
const { active, iconOnly, isFromKeyboard, pointing, primary, underlined, vertical } = props

return {
Expand Down
28 changes: 21 additions & 7 deletions test/specs/commonTests/handlesAccessibility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,22 @@ const TestBehavior: Accessibility = (props: any) => ({
* @param {string} [options.partSelector=''] Selector to scope the test to a part
* @param {FocusZoneDefinition} [options.focusZoneDefinition={}] FocusZone definition
*/
export default (Component, options: any = {}) => {
export default (
Component,
options: {
requiredProps?: any
defaultRootRole?: string
partSelector?: string
focusZoneDefinition?: any
usesWrapperSlot?: boolean
} = {},
) => {
const {
requiredProps = {},
defaultRootRole,
partSelector = '',
focusZoneDefinition = {},
usesWrapperSlot = false,
} = options

test('gets default accessibility when no override used', () => {
Expand Down Expand Up @@ -73,20 +83,24 @@ export default (Component, options: any = {}) => {

test('gets correct role when overrides role', () => {
const testRole = 'test-role'
const rendered = mountWithProviderAndGetComponent(
Component,
<Component {...requiredProps} role={testRole} />,
const element = usesWrapperSlot ? (
<Component {...requiredProps} wrapper={{ role: testRole }} />
) : (
<Component {...requiredProps} role={testRole} />
)
const rendered = mountWithProviderAndGetComponent(Component, element)
const role = getRenderedAttribute(rendered, 'role', partSelector)
expect(role).toBe(testRole)
})

test('gets correct role when overrides both accessibility and role', () => {
const testRole = 'test-role'
const rendered = mountWithProviderAndGetComponent(
Component,
<Component {...requiredProps} accessibility={TestBehavior} role={testRole} />,
const element = usesWrapperSlot ? (
<Component {...requiredProps} accessibility={TestBehavior} wrapper={{ role: testRole }} />
) : (
<Component {...requiredProps} accessibility={TestBehavior} role={testRole} />
)
const rendered = mountWithProviderAndGetComponent(Component, element)
const role = getRenderedAttribute(rendered, 'role', partSelector)
expect(role).toBe(testRole)
})
Expand Down
13 changes: 12 additions & 1 deletion test/specs/commonTests/isConformant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Conformant {
requiredProps?: object
exportedAtTopLevel?: boolean
rendersPortal?: boolean
usesWrapperSlot?: boolean
}

/**
Expand All @@ -32,13 +33,15 @@ export interface Conformant {
* @param {boolean} [options.exportedAtTopLevel=false] Is this component exported as top level API?
* @param {boolean} [options.rendersPortal=false] Does this component render a Portal powered component?
* @param {Object} [options.requiredProps={}] Props required to render Component without errors or warnings.
* @param {boolean} [options.usesWrapperSlot=false] This component uses wrapper slot to wrap the 'meaningful' element.
*/
export default (Component, options: Conformant = {}) => {
const {
eventTargets = {},
exportedAtTopLevel = true,
requiredProps = {},
rendersPortal = false,
usesWrapperSlot = false,
} = options
const { throwError } = helpers('isConformant', Component)

Expand All @@ -58,6 +61,14 @@ export default (Component, options: Conformant = {}) => {
component = component.childAt(0) // skip the additional wrap <div> of the FocusZone
}
}

if (usesWrapperSlot) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

@kuzhelov @levithomason
I think we should avoid changing isConformant tests for the elements with wrapper slot; we had this discussion for Input component.

@miroslavstastny AFAIR, the tests that fail are related to as and className props not being set correctly; these can be solved by addressing the comments I had in MenuItem.tsx where as and styles should always be passed to the root element, being that the a or the li for the MenuItem case.

This probably requires a session where we can discuss options in more detail; @miroslavstastny feel free to schedule a meeting with me and @kuzhelov

Copy link
Member Author

Choose a reason for hiding this comment

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

Thank you very much for the review, @Bugaa92.

So the main question is whether  we should split props between li and a as we do in Input or pass everything to the a.

I am not strictly against doing the split, but when I was considering this I thought it would make more sense to do 'everything to a' although it is different approach than in Input.

We should consider usability and API consistency first and test after that.

Agree to discuss this offline.

Copy link
Contributor

Choose a reason for hiding this comment

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

thanks for sharing your thoughts, @miroslavstastny, @Bugaa92

So the main question is whether we should split props between li and a as we do in Input or pass everything to the a.

If I would answer this question only for the MenuItem component I would rather choose to not split and apply all the props to wrapped <a> element. This would make the following usage case be quite more apparent to me as a user, given that I am aware of wrapper concept

<MenuItem styles={..} href={..} />

It would be really confusing and non-intuitive to me if href will be applied to <a> element, while, at the same time, styles would be applied to <li>. In this situation I would rather expect consistent behavior, i.e. both props being applied to <a>.


At the same time, agree with the @Bugaa92's reasons for the concerns he has expressed, although see Input's situation to be a bit different: while for the menu's case it is a web standard that declares li to be a wrapper, in contrast, for the Input component's case it is a Stardust's need to use wrapper div (what is more interesting, this one is necessary for styling aspects, a bit smelly fact to me).

Agree to strive for consistent and intuitive API, and for this sake, let's, please, schedule a meeting as you've proposed - I will be back staring from 7th of November, but let me know if it should be decided earlier, will do all my best to allocate time for that.

Thank you!

component = component
.childAt(0)
.childAt(0)
.childAt(0)
}

return component
}

Expand Down Expand Up @@ -396,7 +407,7 @@ export default (Component, options: Conformant = {}) => {
.find('[className]')
.hostNodes()
.filterWhere(c => !c.prop(FOCUSZONE_WRAP_ATTRIBUTE)) // filter out FocusZone wrap <div>
.first()
.at(usesWrapperSlot ? 1 : 0)
.prop('className')
return classes
}
Expand Down
14 changes: 9 additions & 5 deletions test/specs/components/Menu/MenuItem-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ describe('MenuItem', () => {
eventTargets: {
onClick: 'a',
},
usesWrapperSlot: true,
})

it('content renders as `li > a`', () => {
const menuItem = mountWithProviderAndGetComponent(MenuItem, <MenuItem content="Home" />).find(
'.ui-menu__item',
)
const menuItem = mountWithProviderAndGetComponent(MenuItem, <MenuItem content="Home" />)
.find('.ui-menu__item__wrapper')
.hostNodes()

expect(menuItem.is('li')).toBe(true)
expect(menuItem.childAt(0).is('a')).toBe(true)
Expand All @@ -24,13 +25,16 @@ describe('MenuItem', () => {

it('children render directly inside `li`', () => {
const menuItem = mountWithProviderAndGetComponent(MenuItem, <MenuItem>Home</MenuItem>)
.find('.ui-menu__item__wrapper')
.hostNodes()

expect(menuItem.find('.ui-menu__item').is('li')).toBe(true)
expect(menuItem.is('li')).toBe(true)
expect(menuItem.childAt(0).exists()).toBe(false)
expect(menuItem.text()).toBe('Home')
})

describe('accessibility', () => {
handlesAccessibility(MenuItem, { defaultRootRole: 'presentation' })
handlesAccessibility(MenuItem, { defaultRootRole: 'presentation', usesWrapperSlot: true })
handlesAccessibility(MenuItem, { defaultRootRole: 'menuitem', partSelector: 'a' })

describe('as a default MenuItem', () => {
Expand Down