From 54a5e98c2e5ca81a87f828b27b9587edb4ff1b03 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Sat, 6 Jul 2019 01:14:03 +0200 Subject: [PATCH 01/13] v1 --- .../Rtl/ContextMenuExample.rtl.steps.ts | 7 + .../Rtl/ContextMenuExample.rtl.tsx | 11 + .../components/ContextMenu/Rtl/index.tsx | 12 + .../Usage/ContextMenuExampleOn.shorthand.tsx | 57 +++++ .../components/ContextMenu/Usage/index.tsx | 16 ++ .../examples/components/ContextMenu/index.tsx | 13 + .../components/ContextMenu/ContextMenu.tsx | 232 ++++++++++++++++++ .../src/components/ContextMenu/focusUtils.ts | 16 ++ packages/react/src/index.ts | 3 + .../ContextMenu/contextMenuBehavior.ts | 71 ++++++ packages/react/src/lib/accessibility/index.ts | 1 + .../src/lib/getOrGenerateIdFromShorthand.ts | 21 +- .../ContextMenu/ContextMenu-test.tsx | 95 +++++++ 13 files changed, 547 insertions(+), 8 deletions(-) create mode 100644 docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.steps.ts create mode 100644 docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.tsx create mode 100644 docs/src/examples/components/ContextMenu/Rtl/index.tsx create mode 100644 docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleOn.shorthand.tsx create mode 100644 docs/src/examples/components/ContextMenu/Usage/index.tsx create mode 100644 docs/src/examples/components/ContextMenu/index.tsx create mode 100644 packages/react/src/components/ContextMenu/ContextMenu.tsx create mode 100644 packages/react/src/components/ContextMenu/focusUtils.ts create mode 100644 packages/react/src/lib/accessibility/Behaviors/ContextMenu/contextMenuBehavior.ts create mode 100644 packages/react/test/specs/components/ContextMenu/ContextMenu-test.tsx diff --git a/docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.steps.ts b/docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.steps.ts new file mode 100644 index 0000000000..193064ad36 --- /dev/null +++ b/docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.steps.ts @@ -0,0 +1,7 @@ +import { Button } from '@stardust-ui/react' + +const config: ScreenerTestsConfig = { + steps: [builder => builder.click(`.${Button.className}`).snapshot('RTL: Shows contextMenu')], +} + +export default config diff --git a/docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.tsx b/docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.tsx new file mode 100644 index 0000000000..fc4c5f411c --- /dev/null +++ b/docs/src/examples/components/ContextMenu/Rtl/ContextMenuExample.rtl.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import { Button, ContextMenu } from '@stardust-ui/react' + +const ContextMenuExampleRtl = () => ( + } + menu={{ items: ['English text!', 'غالباً ونرفض الشعور'] }} + /> +) + +export default ContextMenuExampleRtl diff --git a/docs/src/examples/components/ContextMenu/Rtl/index.tsx b/docs/src/examples/components/ContextMenu/Rtl/index.tsx new file mode 100644 index 0000000000..91b29991a6 --- /dev/null +++ b/docs/src/examples/components/ContextMenu/Rtl/index.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' + +import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample' +import NonPublicSection from 'docs/src/components/ComponentDoc/NonPublicSection' + +const Rtl = () => ( + + + +) + +export default Rtl diff --git a/docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleOn.shorthand.tsx b/docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleOn.shorthand.tsx new file mode 100644 index 0000000000..bd042f7865 --- /dev/null +++ b/docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleOn.shorthand.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import { Alert, Button, Flex, ContextMenu } from '@stardust-ui/react' + +const items = ['1', '2', '3', { content: 'submenu', menu: { items: ['4', '5'] } }] + +class ContextMenuExampleOn extends React.Component { + state = { alert: false } + + showAlert = () => { + this.setState({ alert: true }) + setTimeout(() => this.setState({ alert: false }), 2000) + } + + render() { + return ( + <> + + } + menu={{ items }} + on="click" + /> + } + menu={{ items }} + on="hover" + /> + } + menu={{ items }} + on="focus" + /> + + } + menu={{ items }} + // on="context" + /> + + {this.state.alert && ( + + )} + + ) + } +} + +export default ContextMenuExampleOn diff --git a/docs/src/examples/components/ContextMenu/Usage/index.tsx b/docs/src/examples/components/ContextMenu/Usage/index.tsx new file mode 100644 index 0000000000..75164a9083 --- /dev/null +++ b/docs/src/examples/components/ContextMenu/Usage/index.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' + +import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection' + +const Usage = () => ( + + + +) + +export default Usage diff --git a/docs/src/examples/components/ContextMenu/index.tsx b/docs/src/examples/components/ContextMenu/index.tsx new file mode 100644 index 0000000000..729b8323c7 --- /dev/null +++ b/docs/src/examples/components/ContextMenu/index.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +import Rtl from './Rtl' +import Usage from './Usage' + +const PopupExamples = () => ( + <> + + + +) + +export default PopupExamples diff --git a/packages/react/src/components/ContextMenu/ContextMenu.tsx b/packages/react/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000000..224cea7676 --- /dev/null +++ b/packages/react/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,232 @@ +import * as React from 'react' +import * as PropTypes from 'prop-types' +import * as _ from 'lodash' +import * as customPropTypes from '@stardust-ui/react-proptypes' + +import { + AutoControlledComponent, + RenderResultConfig, + applyAccessibilityKeyHandlers, + getOrGenerateIdFromShorthand, + commonPropTypes, +} from '../../lib' +import { ShorthandValue, Omit } from '../../types' + +import { Accessibility, AccessibilityAttributes } from '../../lib/accessibility/types' +import { createShorthandFactory } from '../../lib/factories' +import Popup, { PopupProps } from '../Popup/Popup' +import { Menu, MenuItemProps, MenuProps, Ref } from '../..' +import { contextMenuBehavior } from '../../lib/accessibility' +import { focusMenuItem, focusNearest } from './focusUtils' +import { ALIGNMENTS, POSITIONS } from '../../lib/positioner' + +export interface ContextMenuSlotClassNames { + menu: string +} + +export interface ContextMenuProps + extends Omit { + /** + * Accessibility behavior if overridden by the user. + * @default contextMenuBehavior + */ + accessibility?: Accessibility + + menu?: ShorthandValue +} + +export interface ContextMenuState { + menuOpen: boolean + menuId: string + triggerId: string + autoFocus: boolean +} + +/** + * A ContextMenu displays a menu connected to trigger element. + * @accessibility + */ +export default class ContextMenu extends AutoControlledComponent< + ContextMenuProps, + ContextMenuState +> { + static displayName = 'ContextMenu' + + static className = 'ui-contextmenu' + + static create: Function + + static slotClassNames: ContextMenuSlotClassNames = { + menu: `${ContextMenu.className}__menu`, + } + + static propTypes = { + ...commonPropTypes.createCommon({ + animated: false, + as: true, + content: false, + }), + align: PropTypes.oneOf(ALIGNMENTS), + defaultOpen: PropTypes.bool, + inline: PropTypes.bool, + mountDocument: PropTypes.object, + mountNode: customPropTypes.domNode, + mouseLeaveDelay: PropTypes.number, + offset: PropTypes.string, + on: PropTypes.oneOfType([ + PropTypes.oneOf(['hover', 'click', 'focus']), + PropTypes.arrayOf(PropTypes.oneOf(['click', 'focus'])), + PropTypes.arrayOf(PropTypes.oneOf(['hover', 'focus'])), + ]), + open: PropTypes.bool, + onOpenChange: PropTypes.func, + pointing: PropTypes.bool, + position: PropTypes.oneOf(POSITIONS), + renderContent: PropTypes.func, + target: PropTypes.any, + trigger: customPropTypes.every([customPropTypes.disallow(['children']), PropTypes.any]), + unstable_pinned: PropTypes.bool, + contentRef: customPropTypes.ref, + menu: customPropTypes.itemShorthand, + } + + static defaultProps: ContextMenuProps = { + accessibility: contextMenuBehavior, + align: 'start', // top + position: 'below', // after + } + + static autoControlledProps = ['open'] + + // TODO: this does not persist generated ids across re-renders, see ContextMenu-test.tsx + static getAutoControlledStateFromProps( + props: ContextMenuProps, + state: ContextMenuState, + ): Partial { + return { + menuId: getOrGenerateIdFromShorthand('contextmenu-menu-', props.menu, state.menuId, true), + triggerId: getOrGenerateIdFromShorthand( + 'contextmenu-trigger-', + props.trigger, + state.triggerId, + true, + ), + } + } + + triggerRef = React.createRef() + menuRef = React.createRef() + + actionHandlers = { + closeAndFocusNext: e => this.closeAndFocus(e, 'next'), + closeAndFocusPrevious: e => this.closeAndFocus(e, 'previous'), + openAndFocusFirst: e => this.openAndFocus(e, 'first'), + openAndFocusLast: e => this.openAndFocus(e, 'last'), + } + + closeAndFocus(e: Event, which: 'next' | 'previous') { + const renderCallback = () => focusNearest(this.triggerRef.current, which) + this.setState( + { + menuOpen: false, + autoFocus: false, + }, + renderCallback, + ) + e.preventDefault() + } + + openAndFocus(e: Event, which: 'first' | 'last') { + const renderCallback = () => focusMenuItem(this.menuRef.current, which) + this.setState( + { + menuOpen: true, + autoFocus: false, // focused by renderCallback + }, + renderCallback, + ) + e.preventDefault() + } + + handleOpenChange = (e, { open }) => { + _.invoke(this.props, 'onOpenChange', e, { ...this.props, ...{ open } }) + this.setState(() => ({ + menuOpen: open, + autoFocus: true, + })) + } + + handleMenuItemClick = (predefinedProps?: MenuItemProps) => ( + e: React.SyntheticEvent, + itemProps: MenuItemProps, + ) => { + _.invoke(predefinedProps, 'onClick', e, itemProps) + if (!predefinedProps || !predefinedProps.menu) { + // do not close if clicked on item with submenu + this.setState({ menuOpen: false, autoFocus: false }) + } + } + + handleMenuItemOverrides = (menuItemAccessibilityAttributes: AccessibilityAttributes) => + _.map(_.get(this.props.menu, 'items'), (item: ShorthandValue) => + typeof item === 'object' + ? { + ...item, + onClick: this.handleMenuItemClick(item as MenuItemProps), + ...menuItemAccessibilityAttributes, + } + : { + content: item, + key: item, + onClick: this.handleMenuItemClick(), + ...menuItemAccessibilityAttributes, + }, + ) + + renderComponent({ + ElementType, + classes, + unhandledProps, + accessibility, + }: RenderResultConfig): React.ReactNode { + const { menu, ...popupProps } = this.props + const content = + menu && + Menu.create(menu, { + defaultProps: { + ...accessibility.attributes.menu, + vertical: true, + }, + overrideProps: { + items: this.handleMenuItemOverrides(accessibility.attributes.menuItem), + }, + }) + + return ( + + + accessibility} + open={this.state.menuOpen} + onOpenChange={this.handleOpenChange} + inline + content={{ + variables: { padding: '', borderSize: '0px' }, + content: content && {content}, + }} + unstable_pinned + /> + + + ) + } +} + +ContextMenu.create = createShorthandFactory({ Component: ContextMenu, mappedProp: 'menu' }) diff --git a/packages/react/src/components/ContextMenu/focusUtils.ts b/packages/react/src/components/ContextMenu/focusUtils.ts new file mode 100644 index 0000000000..960ee518d0 --- /dev/null +++ b/packages/react/src/components/ContextMenu/focusUtils.ts @@ -0,0 +1,16 @@ +import { FocusZoneUtilities, Menu } from '@stardust-ui/react' + +export const focusMenuItem = (menuRef: HTMLElement, order: 'first' | 'last') => { + const selector = `.${Menu.Item.slotClassNames.wrapper}:${order}-child .${Menu.Item.className}` + const element = menuRef.querySelector(selector) + + element.focus() +} + +export const focusNearest = (buttonNode: HTMLElement, order: 'next' | 'previous') => { + const getter = + order === 'next' ? FocusZoneUtilities.getNextElement : FocusZoneUtilities.getPreviousElement + const element = getter(document.body, buttonNode) + + FocusZoneUtilities.focusAsync(element) +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index eb3ad25ca7..dae537287c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -49,6 +49,9 @@ export { default as ChatMessage } from './components/Chat/ChatMessage' export * from './components/Checkbox/Checkbox' export { default as Checkbox } from './components/Checkbox/Checkbox' +export * from './components/ContextMenu/ContextMenu' +export { default as ContextMenu } from './components/ContextMenu/ContextMenu' + export * from './components/Divider/Divider' export { default as Divider } from './components/Divider/Divider' diff --git a/packages/react/src/lib/accessibility/Behaviors/ContextMenu/contextMenuBehavior.ts b/packages/react/src/lib/accessibility/Behaviors/ContextMenu/contextMenuBehavior.ts new file mode 100644 index 0000000000..605b25e824 --- /dev/null +++ b/packages/react/src/lib/accessibility/Behaviors/ContextMenu/contextMenuBehavior.ts @@ -0,0 +1,71 @@ +import * as _ from 'lodash' +import * as keyboardKey from 'keyboard-key' +import { Accessibility } from '../../types' +import { PopupBehaviorProps } from '../Popup/popupBehavior' +import popupAutoFocusBehavior from '../Popup/popupAutoFocusBehavior' + +const contextMenuBehavior: Accessibility = props => { + const behavior = popupAutoFocusBehavior(props) + return _.merge(behavior, { + autoFocus: props.autoFocus, + attributes: { + root: { + role: 'none', + }, + trigger: { + 'aria-controls': props.menuId, + 'aria-expanded': props.menuOpen || undefined, + 'aria-haspopup': 'true', + id: props.triggerId, + tabIndex: props.menuOpen ? -1 : undefined, + }, + + menu: { + 'aria-labelledby': props.triggerId, + id: props.menuId, + }, + + menuItem: { + tabIndex: -1, + }, + }, + keyActions: { + root: { + ...(props.menuOpen + ? { + closeAndFocusNext: { + keyCombinations: [{ keyCode: keyboardKey.Tab, shiftKey: false }], + }, + closeAndFocusPrevious: { + keyCombinations: [{ keyCode: keyboardKey.Tab, shiftKey: true }], + }, + } + : _.includes(props.on, 'click') && { + openAndFocusFirst: { + keyCombinations: [ + { keyCode: keyboardKey.Enter }, + { keyCode: keyboardKey.Space }, + { keyCode: keyboardKey.ArrowDown }, + ], + }, + openAndFocusLast: { + keyCombinations: [{ keyCode: keyboardKey.ArrowUp }], + }, + }), + }, + }, + }) +} + +export interface ContextMenuBehaviorProps extends PopupBehaviorProps { + /** menu id */ + menuId?: string + /** button id */ + triggerId?: string + /** menuOpen */ + menuOpen?: boolean + /** auto focus */ + autoFocus: boolean +} + +export default contextMenuBehavior diff --git a/packages/react/src/lib/accessibility/index.ts b/packages/react/src/lib/accessibility/index.ts index 3e5d6b26de..f5aa6dbd56 100644 --- a/packages/react/src/lib/accessibility/index.ts +++ b/packages/react/src/lib/accessibility/index.ts @@ -35,6 +35,7 @@ export { default as radioGroupItemBehavior } from './Behaviors/Radio/radioGroupI export { default as popupBehavior } from './Behaviors/Popup/popupBehavior' export { default as popupFocusTrapBehavior } from './Behaviors/Popup/popupFocusTrapBehavior' export { default as popupAutoFocusBehavior } from './Behaviors/Popup/popupAutoFocusBehavior' +export { default as contextMenuBehavior } from './Behaviors/ContextMenu/contextMenuBehavior' export { default as chatBehavior } from './Behaviors/Chat/chatBehavior' export { default as chatMessageBehavior } from './Behaviors/Chat/chatMessageBehavior' export { default as gridBehavior } from './Behaviors/Grid/gridBehavior' diff --git a/packages/react/src/lib/getOrGenerateIdFromShorthand.ts b/packages/react/src/lib/getOrGenerateIdFromShorthand.ts index 875dce59b4..0cf607c270 100644 --- a/packages/react/src/lib/getOrGenerateIdFromShorthand.ts +++ b/packages/react/src/lib/getOrGenerateIdFromShorthand.ts @@ -6,20 +6,25 @@ const getOrGenerateIdFromShorthand = ( prefix: string, value: ShorthandValue, currentValue?: string, + fallbackToUnique?: boolean, ): string | undefined => { - if (_.isNil(value)) { - return undefined - } + let result: string - if (React.isValidElement(value)) { - return (value as React.ReactElement<{ id?: string }>).props.id + if (_.isNil(value)) { + result = undefined + } else if (React.isValidElement(value)) { + result = (value as React.ReactElement<{ id?: string }>).props.id + } else if (_.isPlainObject(value)) { + result = (value as Record).id + } else { + result = currentValue || _.uniqueId(prefix) } - if (_.isPlainObject(value)) { - return (value as Record).id + if (fallbackToUnique && !result) { + result = _.uniqueId(prefix) } - return currentValue || _.uniqueId(prefix) + return result } export default getOrGenerateIdFromShorthand diff --git a/packages/react/test/specs/components/ContextMenu/ContextMenu-test.tsx b/packages/react/test/specs/components/ContextMenu/ContextMenu-test.tsx new file mode 100644 index 0000000000..22235ebe27 --- /dev/null +++ b/packages/react/test/specs/components/ContextMenu/ContextMenu-test.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' + +import ContextMenu from 'src/components/ContextMenu/ContextMenu' +import { isConformant, handlesAccessibility } from 'test/specs/commonTests' +import { mountWithProvider } from '../../../utils' + +const mockMenu = { items: ['1', '2', '3'] } + +describe('ContextMenu', () => { + isConformant(ContextMenu) + + describe('accessibility', () => { + handlesAccessibility(ContextMenu, { + defaultRootRole: 'none', + }) + + describe('onOpenChange', () => { + test('is called on click', () => { + const spy = jest.fn() + + mountWithProvider(} menu={mockMenu} onOpenChange={spy} />) + .find('button') + .simulate('click') + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0][1]).toMatchObject({ open: true }) + }) + + test('is called on click when controlled', () => { + const spy = jest.fn() + + mountWithProvider( + } menu={mockMenu} onOpenChange={spy} />, + ) + .find('button') + .simulate('click') + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0][1]).toMatchObject({ open: true }) + }) + }) + + describe('ID handling', () => { + test('trigger id is used', () => { + const contextMenu = mountWithProvider( + } menu={mockMenu} />, + ) + const button = contextMenu.find('button') + button.simulate('click') + const menu = contextMenu.find('ul') + const triggerId = button.prop('id') + + expect(triggerId).toEqual('test-id') + expect(menu.prop('aria-labelledby')).toEqual(triggerId) + }) + + test('trigger id is generated if not provided', () => { + const contextMenu = mountWithProvider(} menu={mockMenu} />) + const button = contextMenu.find('button') + button.simulate('click') + // const menu = contextMenu.find('ul') + const triggerId = button.prop('id') + + expect(triggerId).toMatch(/contextmenu-trigger-\d+/) + // TODO: component does not persist generated ids across re-renders + // expect(menu.prop('aria-labelledby')).toEqual(triggerId) + }) + + test('menu id is used', () => { + const contextMenu = mountWithProvider( + } menu={{ ...mockMenu, id: 'test-id' }} />, + ) + const button = contextMenu.find('button') + button.simulate('click') + const menu = contextMenu.find('ul') + const menuId = menu.prop('id') + + expect(menuId).toEqual('test-id') + expect(button.prop('aria-controls')).toEqual(menuId) + }) + + test('menu id is generated if not provided', () => { + const contextMenu = mountWithProvider(} menu={mockMenu} />) + const button = contextMenu.find('button') + button.simulate('click') + const menu = contextMenu.find('ul') + const menuId = menu.prop('id') + + expect(menuId).toMatch(/contextmenu-menu-\d+/) + // TODO: component does not persist generated ids across re-renders + // expect(button.prop('aria-controls')).toEqual(menuId) + }) + }) + }) +}) From c67c939d9961d6c9b94518ebfeb97700c423b7e8 Mon Sep 17 00:00:00 2001 From: Juraj Kapsiar Date: Mon, 22 Jul 2019 07:25:02 +0200 Subject: [PATCH 02/13] wip --- ...ContextMenuExampleControlled.shorthand.tsx | 20 +++++++++++ .../Usage/ContextMenuExampleOn.shorthand.tsx | 2 +- .../ContextMenuExampleOnElement.shorthand.tsx | 33 +++++++++++++++++++ .../components/ContextMenu/Usage/index.tsx | 10 ++++++ .../components/ContextMenu/ContextMenu.tsx | 26 ++++++++------- .../src/components/ContextMenu/focusUtils.ts | 14 +++++--- packages/react/src/index.ts | 4 +++ .../ContextMenu/contextMenuBehavior.ts | 18 ++++------ packages/react/src/lib/accessibility/index.ts | 1 + 9 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleControlled.shorthand.tsx create mode 100644 docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleOnElement.shorthand.tsx diff --git a/docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleControlled.shorthand.tsx b/docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleControlled.shorthand.tsx new file mode 100644 index 0000000000..86fd4ce8f8 --- /dev/null +++ b/docs/src/examples/components/ContextMenu/Usage/ContextMenuExampleControlled.shorthand.tsx @@ -0,0 +1,20 @@ +import { useBooleanKnob } from '@stardust-ui/docs-components' +import * as React from 'react' +import { Button, ContextMenu } from '@stardust-ui/react' + +const items = ['1', '2', '3', { content: 'submenu', menu: { items: ['4', '5'] } }] + +const ContextMenuControlledExample = () => { + const [open, setOpen] = useBooleanKnob({ name: 'open', initialValue: true }) + + return ( + setOpen(open)} + trigger={