diff --git a/UNRELEASED.md b/UNRELEASED.md index 11c1285e7a1..bf460c560b4 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -12,6 +12,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Added `onKeyPress`, `onKeyDown`, and `onKeyUp` to `Button` ([#860](https://github.com/Shopify/polaris-react/pull/860)) - Added `monochrome` prop to `Button` and `Link` component ([#821](https://github.com/Shopify/polaris-react/pull/821)) +- Updated `Frame` layout and made `TopBar.UserMenu` visible on mobile ([#852](https://github.com/Shopify/polaris-react/pull/852)) ### Design updates diff --git a/src/components/Frame/Frame.tsx b/src/components/Frame/Frame.tsx index 4e30bc5c38d..b0721046ae7 100644 --- a/src/components/Frame/Frame.tsx +++ b/src/components/Frame/Frame.tsx @@ -9,6 +9,7 @@ import EventListener from '../EventListener'; import {withAppProvider, WithAppProviderProps} from '../AppProvider'; import Backdrop from '../Backdrop'; import TrapFocus from '../TrapFocus'; +import {UserMenuProvider} from '../TopBar'; import {dataPolarisTopBar, layer, Duration} from '../shared'; import {setRootProperty} from '../../utilities/setRootProperty'; import { @@ -246,11 +247,13 @@ export class Frame extends React.PureComponent { {...navigationAttributes} > {skipMarkup} - {topBarMarkup} + + {topBarMarkup} + {navigationMarkup} + {contextualSaveBarMarkup} {loadingMarkup} {navigationOverlayMarkup} - {navigationMarkup}
', () => { ); expect(frame.find(FrameLoading).exists()).toBe(true); }); + + describe('', () => { + it('renders', () => { + const frame = mountWithAppProvider(); + expect(frame.find(UserMenuProvider).exists()).toBe(true); + }); + + it('receives a mobileView boolean', () => { + const frame = mountWithAppProvider(); + expect(frame.find(UserMenuProvider).prop('mobileView')).toBe(false); + }); + + it('receives the given top bar and navigation as its children', () => { + const topBar = ; + const navigation = ; + const frame = mountWithAppProvider( + , + ); + expect(frame.find(UserMenuProvider).contains(topBar)).toBe(true); + expect(frame.find(UserMenuProvider).contains(navigation)).toBe(true); + }); + }); }); diff --git a/src/components/Navigation/Navigation.scss b/src/components/Navigation/Navigation.scss index 4edd9f4c776..ca6adddd65e 100644 --- a/src/components/Navigation/Navigation.scss +++ b/src/components/Navigation/Navigation.scss @@ -22,7 +22,6 @@ $nav-max-width: rem(360px); @include breakpoint-after(nav-min-window-corrected()) { max-width: layout-width(nav); - border-right: border(); @include safe-area-for(max-width, layout-width(nav), left); } } diff --git a/src/components/Navigation/components/UserMenu/UserMenu.tsx b/src/components/Navigation/components/UserMenu/UserMenu.tsx index d1401c74684..1dcf9cb3941 100644 --- a/src/components/Navigation/components/UserMenu/UserMenu.tsx +++ b/src/components/Navigation/components/UserMenu/UserMenu.tsx @@ -1,18 +1,9 @@ import * as React from 'react'; - -import {classNames} from '@shopify/react-utilities/styles'; -import {autobind, memoize} from '@shopify/javascript-utilities/decorators'; - +import {autobind} from '@shopify/javascript-utilities/decorators'; +import {UserMenuModifier} from '../../../TopBar'; import {IconableAction} from '../../../../types'; - -import Avatar, {Props as AvatarProps} from '../../../Avatar'; -import MessageIndicator from '../../../MessageIndicator'; -import Icon from '../../../Icon'; -import UnstyledLink from '../../../UnstyledLink'; - -import Message, {Props as MessageProps} from '../Message'; - -import * as styles from './UserMenu.scss'; +import {Props as MessageProps} from '../Message'; +import {Props as AvatarProps} from '../../../Avatar'; interface UserActionSection { id: string; @@ -29,155 +20,44 @@ export interface Props { } interface State { - userMenuExpanded?: boolean; + open: boolean; } -export default class UserMenu extends React.PureComponent { - state: State = { - userMenuExpanded: false, +class UserMenu extends React.Component { + state = { + open: false, }; render() { const { name, detail, - avatarInitials, - avatarSource, actions, message, + avatarInitials, + avatarSource, } = this.props; - const {userMenuExpanded} = this.state; - - const className = classNames( - styles.UserMenu, - userMenuExpanded && styles.expanded, - ); + const {open} = this.state; - const itemClassName = styles.Item; - const tabIndex = userMenuExpanded ? 0 : -1; - - const itemsMarkup = - actions && - actions.map((section) => { - return ( -
- {section.items.map((item) => { - const icon = item.icon; - return item.url ? ( - - {icon && ( - - - - )} - {item.content} - - ) : ( - - ); - })} -
- ); - }); - - const badgeProps = message && - message.badge && { - content: message.badge.content, - status: message.badge.status, - }; - const messageMarkup = message && ( -
- -
- ); - - const showIndicator = Boolean(message); - - return ( -
- -
- {itemsMarkup} - {messageMarkup} -
-
- ); - } - - @memoize() - private createActionHandler(handler: () => void) { - return () => { - handler(); - this.handleClick(); + const userMenuProps = { + actions: actions || [], + message, + name: name || '', + detail, + initials: avatarInitials, + avatar: avatarSource, + onToggle: this.handleToggle, + open, }; + + return ; } @autobind - private handleClick() { - this.setState(({userMenuExpanded}) => ({ - userMenuExpanded: !userMenuExpanded, - })); + private handleToggle() { + const {open} = this.state; + this.setState({open: !open}); } } -function handleMouseUp({currentTarget}: React.MouseEvent) { - currentTarget.blur(); -} +export default UserMenu; diff --git a/src/components/Navigation/components/UserMenu/tests/User.test.tsx b/src/components/Navigation/components/UserMenu/tests/User.test.tsx index 21644b69bd7..339fb5dfc0c 100644 --- a/src/components/Navigation/components/UserMenu/tests/User.test.tsx +++ b/src/components/Navigation/components/UserMenu/tests/User.test.tsx @@ -1,59 +1,104 @@ import * as React from 'react'; -import {noop} from '@shopify/javascript-utilities/other'; -import {mountWithAppProvider} from 'test-utilities'; -import UserMenu from '../UserMenu'; - -const actions = [ - { - id: '123', - items: [ - { - content: 'notification', - icon: 'notification' as 'notification', - onAction: noop, - }, - ], - }, -]; - -const message = { - title: 'test message', - description: 'test description', - link: {to: '/', content: 'Home'}, - action: { - onClick: noop, - content: 'New', - }, - badge: { - content: 'flashy new home card', - status: 'new' as 'new', - }, -}; - -const userProps = { - name: 'Andrew Musgrave', - detail: 'FED', - actions, - message, - avatarInitials: 'am', -}; +import {mountWithAppProvider, trigger} from 'test-utilities'; +import {UserMenuModifier} from '../../../../TopBar'; +import UserMenu, {Props as UserMenuProps} from '../UserMenu'; describe('', () => { - it('mounts', () => { - const user = mountWithAppProvider(); + const mockProps = { + avatarInitials: '', + avatarSource: '', + }; - expect(user.exists()).toBe(true); + describe('avatarInitials', () => { + it('gets passed into the modifier', () => { + const avatarInitials = 'JD'; + const userMenu = mountWithAppProvider( + , + ); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({initials: avatarInitials}), + ); + }); }); - it('passes the actions prop', () => { - const user = mountWithAppProvider(); + describe('avatarSource', () => { + it('gets passed into the modifier', () => { + const avatarSource = ''; + const userMenu = mountWithAppProvider( + , + ); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({avatar: avatarSource}), + ); + }); + }); + + describe('message', () => { + it('gets passed into the modifier', () => { + const message = {} as UserMenuProps['message']; + const userMenu = mountWithAppProvider( + , + ); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({message}), + ); + }); + }); + + describe('actions', () => { + it('gets passed into the modifier', () => { + const actions = [] as UserMenuProps['actions']; + const userMenu = mountWithAppProvider( + , + ); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({actions}), + ); + }); + }); + + describe('detail', () => { + it('gets passed into the modifier', () => { + const detail = 'Little Victories CA'; + const userMenu = mountWithAppProvider( + , + ); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({detail}), + ); + }); + }); - expect(user.prop('actions')).toBe(actions); + describe('name', () => { + it('gets passed into the modifier', () => { + const name = 'John Doe'; + const userMenu = mountWithAppProvider( + , + ); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({name}), + ); + }); }); - it('passes the message prop', () => { - const user = mountWithAppProvider(); + describe('', () => { + it('passes in an open prop which is false by default', () => { + const userMenu = mountWithAppProvider(); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({open: false}), + ); + }); - expect(user.prop('message')).toBe(message); + it('toggles the open prop when the user menu is toggled', () => { + const userMenu = mountWithAppProvider(); + trigger(userMenu.find(UserMenuModifier), 'userMenuProps.onToggle'); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({open: true}), + ); + trigger(userMenu.find(UserMenuModifier), 'userMenuProps.onToggle'); + expect(userMenu.find(UserMenuModifier).prop('userMenuProps')).toEqual( + expect.objectContaining({open: false}), + ); + }); }); }); diff --git a/src/components/Navigation/tests/Navigation.test.tsx b/src/components/Navigation/tests/Navigation.test.tsx index 7ffb085e154..31b98e6af82 100644 --- a/src/components/Navigation/tests/Navigation.test.tsx +++ b/src/components/Navigation/tests/Navigation.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import {mountWithAppProvider} from 'test-utilities'; import Navigation from '../Navigation'; +import {UserMenu} from '../components'; const childContextTypes = { location: PropTypes.string, @@ -32,4 +33,14 @@ describe('', () => { expect(div.exists()).toBe(true); }); + + describe('userMenu', () => { + it('renders the given user menu', () => { + const userMenu = ; + const navigation = mountWithAppProvider( + , + ); + expect(navigation.contains(userMenu)).toBeTruthy(); + }); + }); }); diff --git a/src/components/TopBar/TopBar.scss b/src/components/TopBar/TopBar.scss index 06e7637fbe7..183ebe1cd71 100644 --- a/src/components/TopBar/TopBar.scss +++ b/src/components/TopBar/TopBar.scss @@ -1,5 +1,8 @@ @import './styles/variables'; + $icon-size: rem(20px); +$page-left-alignment-breakpoint-max: 1268px; + .TopBar { position: relative; display: flex; @@ -38,6 +41,7 @@ $icon-size: rem(20px); position: relative; align-self: center; margin-left: spacing(tight) + rem(2px); + margin-right: spacing(tight); padding: spacing(tight); border-radius: border-radius(); &.focused { @@ -71,3 +75,21 @@ $icon-size: rem(20px); justify-content: flex-end; height: 100%; } + +.SearchField { + @include page-layout; + width: 100%; + margin: 0; + max-width: none; + margin-left: calc((100% - #{$page-max-width}) / 2); + + @media (max-width: $page-left-alignment-breakpoint-max) { + margin-left: 0; + } +} + +.SecondaryMenu { + @include breakpoint-before(layout-width(page-with-nav), false) { + display: none; + } +} diff --git a/src/components/TopBar/TopBar.tsx b/src/components/TopBar/TopBar.tsx index 9ee08975135..55c03510f12 100644 --- a/src/components/TopBar/TopBar.tsx +++ b/src/components/TopBar/TopBar.tsx @@ -120,8 +120,8 @@ export class TopBar extends React.PureComponent { {navigationButtonMarkup}
{logoMarkup}
- {searchMarkup} - {secondaryMenu} +
{searchMarkup}
+
{secondaryMenu}
{userMenu}
diff --git a/src/components/TopBar/components/Menu/Menu.scss b/src/components/TopBar/components/Menu/Menu.scss index 85d233cd3ba..b61647a5ff1 100644 --- a/src/components/TopBar/components/Menu/Menu.scss +++ b/src/components/TopBar/components/Menu/Menu.scss @@ -5,6 +5,7 @@ $activator-variables: ( focus-background-color: rgba(color('white'), 0.16), hover-background-color: rgba(color('white'), 0.08), active-background-color: rgba(color('black'), 0.28), + focus-opacity: 0.85, ); @function menu($variable) { @@ -23,14 +24,9 @@ $activator-variables: ( margin: 0; padding: spacing(tight) spacing(); border: 0; - border-left: menu(border-left); cursor: pointer; transition: menu(transition); - @include breakpoint-before(layout-width(page-with-nav), false) { - display: none; - } - &:focus { background-color: menu(focus-background-color); outline: none; @@ -46,6 +42,16 @@ $activator-variables: ( outline: none; transition: none; } + + @include breakpoint-before(layout-width(page-with-nav), false) { + &:focus, + &:hover, + &:active, + &[aria-expanded='true'] { + background-color: transparent; + opacity: menu(focus-opacity); + } + } } .Section { diff --git a/src/components/TopBar/components/SearchField/SearchField.scss b/src/components/TopBar/components/SearchField/SearchField.scss index 9098ffa2d65..d90aaaf0cf1 100644 --- a/src/components/TopBar/components/SearchField/SearchField.scss +++ b/src/components/TopBar/components/SearchField/SearchField.scss @@ -16,16 +16,8 @@ $stacking-order: ( flex: 1 1 auto; align-items: center; border: 1px solid transparent; - - margin: 0 spacing(); - - @include page-content-when-not-fully-condensed { - margin: 0 spacing(loose); - } - - @include page-content-when-not-partially-condensed { - margin: 0 spacing(extra-loose); - } + width: 100%; + max-width: rem(700px); } // We have both a focused class and a focus pseudo selector here diff --git a/src/components/TopBar/components/UserMenu/UserMenu.tsx b/src/components/TopBar/components/UserMenu/UserMenu.tsx index a4ee75a75e4..5550ac93e71 100644 --- a/src/components/TopBar/components/UserMenu/UserMenu.tsx +++ b/src/components/TopBar/components/UserMenu/UserMenu.tsx @@ -1,70 +1,21 @@ import * as React from 'react'; -import {IconableAction} from '../../../../types'; +import withContext from '../../../WithContext'; +import {WithContextTypes} from '../../../../types'; +import {Consumer as UserMenuConsumer, UserMenuContextTypes} from './context'; +import {UserMenu as UserMenuComponent, UserMenuProps} from './components'; -import Avatar, {Props as AvatarProps} from '../../../Avatar'; -import MessageIndicator from '../../../MessageIndicator'; +type ComposedProps = UserMenuProps & WithContextTypes; -import Menu, {MessageProps} from '../Menu'; - -import * as styles from './UserMenu.scss'; - -export interface Props { - /** An array of action objects that are rendered inside of a popover triggered by this menu */ - actions: {items: IconableAction[]}[]; - /** Accepts a message that facilitates direct, urgent communication with the merchant through the user menu */ - message?: MessageProps; - /** A string detailing the merchant’s full name to be displayed in the user menu */ - name: string; - /** A string allowing further details on the merchant’s name displayed in the user menu */ - detail: string; - /** The merchant’s initials, rendered in place of an avatar image when not provided */ - initials: AvatarProps['initials']; - /** An avatar image representing the merchant */ - avatar?: AvatarProps['source']; - /** A boolean property indicating whether the user menu is currently open */ - open: boolean; - /** A callback function to handle opening and closing the user menu */ - onToggle(): void; +function UserMenu({ + context: {mobileUserMenuProps, mobileView}, + ...userMenuProps +}: ComposedProps) { + if (mobileUserMenuProps && mobileView) { + return ; + } + return ; } -export default function UserMenu({ - name, - detail, - avatar, - initials, - actions, - message, - onToggle, - open, -}: Props) { - const showIndicator = Boolean(message); - - const activatorContentMarkup = ( -
- - - - -

- {name} -

-

{detail}

-
-
- ); - - return ( - - ); -} +export default withContext( + UserMenuConsumer, +)(UserMenu); diff --git a/src/components/TopBar/components/UserMenu/UserMenu.scss b/src/components/TopBar/components/UserMenu/components/UserMenu/UserMenu.scss similarity index 77% rename from src/components/TopBar/components/UserMenu/UserMenu.scss rename to src/components/TopBar/components/UserMenu/components/UserMenu/UserMenu.scss index 6405f9de3ad..6b9810fd146 100644 --- a/src/components/TopBar/components/UserMenu/UserMenu.scss +++ b/src/components/TopBar/components/UserMenu/components/UserMenu/UserMenu.scss @@ -6,14 +6,21 @@ $large-width: layout-width(primary, max) + layout-width(secondary, max) + .UserMenu { display: flex; - + align-items: center; @include safe-area-for(padding-right, spacing(tight), right); + + @include breakpoint-before(layout-width(page-with-nav), false) { + padding-right: 0; + } } .Details { max-width: rem(160px); - margin-right: spacing(loose); margin-left: spacing(); + + @include breakpoint-before(layout-width(page-with-nav), false) { + display: none; + } } .Name { diff --git a/src/components/TopBar/components/UserMenu/components/UserMenu/UserMenu.tsx b/src/components/TopBar/components/UserMenu/components/UserMenu/UserMenu.tsx new file mode 100644 index 00000000000..a2ac7b72c6d --- /dev/null +++ b/src/components/TopBar/components/UserMenu/components/UserMenu/UserMenu.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import {IconableAction} from '../../../../../../types'; +import Avatar, {Props as AvatarProps} from '../../../../../Avatar'; +import MessageIndicator from '../../../../../MessageIndicator'; +import Menu, {MessageProps} from '../../../Menu'; +import * as styles from './UserMenu.scss'; + +export interface Props { + /** An array of action objects that are rendered inside of a popover triggered by this menu */ + actions: {items: IconableAction[]}[]; + /** Accepts a message that facilitates direct, urgent communication with the merchant through the user menu */ + message?: MessageProps; + /** A string detailing the merchant’s full name to be displayed in the user menu */ + name: string; + /** A string allowing further details on the merchant’s name displayed in the user menu */ + detail?: string; + /** The merchant’s initials, rendered in place of an avatar image when not provided */ + initials: AvatarProps['initials']; + /** An avatar image representing the merchant */ + avatar?: AvatarProps['source']; + /** A boolean property indicating whether the user menu is currently open */ + open: boolean; + /** A callback function to handle opening and closing the user menu */ + onToggle(): void; +} + +function UserMenu({ + name, + detail, + avatar, + initials, + actions, + message, + onToggle, + open, +}: Props) { + const showIndicator = Boolean(message); + + const activatorContentMarkup = ( +
+ + + + +

+ {name} +

+

{detail}

+
+
+ ); + + return ( + + ); +} + +export default UserMenu; diff --git a/src/components/TopBar/components/UserMenu/components/UserMenu/index.ts b/src/components/TopBar/components/UserMenu/components/UserMenu/index.ts new file mode 100644 index 00000000000..61c86fc8319 --- /dev/null +++ b/src/components/TopBar/components/UserMenu/components/UserMenu/index.ts @@ -0,0 +1 @@ +export {default, Props} from './UserMenu'; diff --git a/src/components/TopBar/components/UserMenu/components/UserMenu/tests/UserMenu.test.tsx b/src/components/TopBar/components/UserMenu/components/UserMenu/tests/UserMenu.test.tsx new file mode 100644 index 00000000000..77dbf467b17 --- /dev/null +++ b/src/components/TopBar/components/UserMenu/components/UserMenu/tests/UserMenu.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import {ReactWrapper} from 'enzyme'; +import {noop} from '@shopify/javascript-utilities/other'; +import {mountWithAppProvider} from 'test-utilities'; +import Menu from '../../../../Menu'; +import UserMenu from '../UserMenu'; + +const actions = [ + {items: [{icon: 'notification' as 'notification', onAction: noop}]}, +]; +const message = { + title: 'test message', + description: 'test description', + link: {to: '/', content: 'Home'}, + action: { + onClick: noop, + content: 'New', + }, + badge: { + content: 'flashy new home card', + status: 'new' as 'new', + }, +}; +const userProps = { + actions, + name: 'Andrew Musgrave', + detail: 'FED', + initials: 'am', + open: true, + onToggle: noop, + message, +}; + +describe('', () => { + it('mounts', () => { + const user = mountWithAppProvider(); + expect(user).toBeTruthy(); + }); + + it('constructs activatorContent and passes it down to menu', () => { + const user = mountWithAppProvider(); + expect(returnMenuProp(user, 'activatorContent')).toBeTruthy(); + }); + + it('passes the open prop down to menu', () => { + const user = mountWithAppProvider(); + expect(returnMenuProp(user, 'open')).toBe(true); + }); + + it('passes the actions prop down to menu', () => { + const user = mountWithAppProvider(); + expect(returnMenuProp(user, 'actions')).toBe(actions); + }); + + it('passes the message prop down to menu', () => { + const user = mountWithAppProvider(); + expect(returnMenuProp(user, 'message')).toBe(message); + }); + + describe('onToggle', () => { + it('passes the onToggle prop down to menu as onOpen', () => { + const user = mountWithAppProvider(); + expect(returnMenuProp(user, 'onOpen')).toBe(noop); + }); + + it('passes the onToggle prop down to menu as onClose', () => { + const user = mountWithAppProvider(); + expect(returnMenuProp(user, 'onClose')).toBe(noop); + }); + }); +}); + +function returnMenuProp(wrapper: ReactWrapper, prop: string) { + return wrapper.find(Menu).prop(prop); +} diff --git a/src/components/TopBar/components/UserMenu/components/index.ts b/src/components/TopBar/components/UserMenu/components/index.ts new file mode 100644 index 00000000000..ffa419cd1f9 --- /dev/null +++ b/src/components/TopBar/components/UserMenu/components/index.ts @@ -0,0 +1 @@ +export {default as UserMenu, Props as UserMenuProps} from './UserMenu'; diff --git a/src/components/TopBar/components/UserMenu/context/Consumer.ts b/src/components/TopBar/components/UserMenu/context/Consumer.ts new file mode 100644 index 00000000000..c79754f5d02 --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/Consumer.ts @@ -0,0 +1,3 @@ +import UserMenuContext from './context'; + +export default UserMenuContext.Consumer; diff --git a/src/components/TopBar/components/UserMenu/context/Modifier.tsx b/src/components/TopBar/components/UserMenu/context/Modifier.tsx new file mode 100644 index 00000000000..156891da196 --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/Modifier.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import withContext from '../../../../WithContext'; +import {WithContextTypes} from '../../../../../types'; +import {UserMenuProps} from '../components'; +import Consumer from './Consumer'; +import {UserMenuContextTypes} from './context'; + +interface Props { + userMenuProps: UserMenuProps; +} + +type ComposedProps = Props & WithContextTypes; + +class Modifier extends React.Component { + static getDerivedStateFromProps({ + context: {setMobileUserMenuProps}, + userMenuProps, + }: ComposedProps) { + if (setMobileUserMenuProps) { + setMobileUserMenuProps(userMenuProps); + } + return null; + } + + state = {}; + + render() { + return null; + } +} + +export default withContext(Consumer)(Modifier); diff --git a/src/components/TopBar/components/UserMenu/context/Provider.tsx b/src/components/TopBar/components/UserMenu/context/Provider.tsx new file mode 100644 index 00000000000..571003421cd --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/Provider.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import isEqual from 'lodash/isEqual'; +import {autobind} from '@shopify/javascript-utilities/decorators'; +import {UserMenuProps} from '../components'; +import UserMenuContext, {UserMenuContextTypes} from './context'; + +interface Props { + mobileView: boolean; + children: React.ReactNode; +} + +class Provider extends React.Component { + static getDerivedStateFromProps( + {mobileView: nextMobileView}: Props, + {mobileView}: UserMenuContextTypes, + ) { + if (nextMobileView !== mobileView) { + return {mobileView: nextMobileView}; + } + return null; + } + + state = { + // eslint-disable-next-line react/no-unused-state + mobileView: this.props.mobileView, + mobileUserMenuProps: undefined, + // eslint-disable-next-line react/no-unused-state + setMobileUserMenuProps: this.setMobileUserMenuProps, + }; + + render() { + const {state} = this; + const {children} = this.props; + return ( + + {children} + + ); + } + + @autobind + private setMobileUserMenuProps(mobileUserMenuProps: UserMenuProps) { + const {mobileUserMenuProps: prevMobileUserMenuProps} = this.state; + if (isEqual(mobileUserMenuProps, prevMobileUserMenuProps)) { + return; + } + this.setState({mobileUserMenuProps}); + } +} + +export default Provider; diff --git a/src/components/TopBar/components/UserMenu/context/context.ts b/src/components/TopBar/components/UserMenu/context/context.ts new file mode 100644 index 00000000000..0219c4bdf36 --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/context.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import {UserMenuProps} from '../components'; + +export interface UserMenuContextTypes { + mobileView?: boolean; + mobileUserMenuProps?: UserMenuProps; + setMobileUserMenuProps?(props: UserMenuProps): void; +} + +export default React.createContext({}); diff --git a/src/components/TopBar/components/UserMenu/context/index.ts b/src/components/TopBar/components/UserMenu/context/index.ts new file mode 100644 index 00000000000..55734b305aa --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/index.ts @@ -0,0 +1,4 @@ +export {default as Provider} from './Provider'; +export {default as Consumer} from './Consumer'; +export {default as Modifier} from './Modifier'; +export {default as UserMenuContext, UserMenuContextTypes} from './context'; diff --git a/src/components/TopBar/components/UserMenu/context/tests/Modifier.test.tsx b/src/components/TopBar/components/UserMenu/context/tests/Modifier.test.tsx new file mode 100644 index 00000000000..a1f10c82eda --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/tests/Modifier.test.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import {noop} from '@shopify/javascript-utilities/other'; +import {mountWithAppProvider} from 'test-utilities'; +import {UserMenuProps} from '../../components'; +import UserMenuContext from '../context'; +import Modifier from '../Modifier'; + +describe('', () => { + const userMenuProps: UserMenuProps = { + actions: [{items: [{icon: 'view'}]}], + name: '', + initials: '', + open: false, + onToggle: noop, + }; + + describe('userMenuProps', () => { + it('sets the mobile user menu props', () => { + const setMobileUserMenuPropsSpy = jest.fn(); + mountWithAppProvider( + + + , + ); + expect(setMobileUserMenuPropsSpy).toHaveBeenCalledWith(userMenuProps); + }); + }); +}); diff --git a/src/components/TopBar/components/UserMenu/context/tests/Provider.test.tsx b/src/components/TopBar/components/UserMenu/context/tests/Provider.test.tsx new file mode 100644 index 00000000000..ca98d4df6fa --- /dev/null +++ b/src/components/TopBar/components/UserMenu/context/tests/Provider.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import {mountWithAppProvider, trigger} from 'test-utilities'; +import UserMenuContext from '../context'; +import Provider from '../Provider'; + +jest.mock('../context', () => ({ + Provider: ({children}: {children: React.ReactNode}) => children, +})); + +describe('', () => { + const mockProps = { + mobileView: false, + children: null, + }; + + describe('mobileView', () => { + it('gets passed into the context provider', () => { + const mobileView = true; + const provider = mountWithAppProvider( + , + ); + expect(provider.find(UserMenuContext.Provider).prop('value')).toEqual( + expect.objectContaining({ + mobileView, + }), + ); + }); + + it('updates the context provider when it changes', () => { + const mobileView = true; + const newMobileView = false; + const provider = mountWithAppProvider( + , + ); + provider.setProps({mobileView: newMobileView}); + expect(provider.find(UserMenuContext.Provider).prop('value')).toEqual( + expect.objectContaining({ + mobileView: newMobileView, + }), + ); + }); + }); + + describe('children', () => { + it('get passed into the context provider', () => { + const children =
; + const provider = mountWithAppProvider( + {children}, + ); + expect( + provider.find(UserMenuContext.Provider).contains(children), + ).toBeTruthy(); + }); + }); + + describe('', () => { + it('receives updated menu props when setMobileUserMenuProps is called', () => { + const newUserMenuProps = {initials: 'JD'}; + const provider = mountWithAppProvider(); + trigger( + provider.find(UserMenuContext.Provider), + 'value.setMobileUserMenuProps', + newUserMenuProps, + ); + expect(provider.find(UserMenuContext.Provider).prop('value')).toEqual( + expect.objectContaining({ + mobileUserMenuProps: newUserMenuProps, + }), + ); + }); + }); +}); diff --git a/src/components/TopBar/components/UserMenu/index.ts b/src/components/TopBar/components/UserMenu/index.ts index a3e42e57201..8f6d87393ff 100644 --- a/src/components/TopBar/components/UserMenu/index.ts +++ b/src/components/TopBar/components/UserMenu/index.ts @@ -1,4 +1,5 @@ import UserMenu from './UserMenu'; -export {Props} from './UserMenu'; +export {UserMenuProps as Props} from './components'; +export {Provider, Modifier} from './context'; export default UserMenu; diff --git a/src/components/TopBar/components/UserMenu/tests/UserMenu.test.tsx b/src/components/TopBar/components/UserMenu/tests/UserMenu.test.tsx index 57b1f15023a..175cba000e2 100644 --- a/src/components/TopBar/components/UserMenu/tests/UserMenu.test.tsx +++ b/src/components/TopBar/components/UserMenu/tests/UserMenu.test.tsx @@ -1,82 +1,78 @@ import * as React from 'react'; -import {ReactWrapper} from 'enzyme'; import {noop} from '@shopify/javascript-utilities/other'; import {mountWithAppProvider} from 'test-utilities'; +import {UserMenuContext} from '../context'; +import {UserMenu as UserMenuComponent, UserMenuProps} from '../components'; import UserMenu from '../UserMenu'; -import Menu from '../../Menu'; - -const actions = [ - {items: [{icon: 'notification' as 'notification', onAction: noop}]}, -]; -const message = { - title: 'test message', - description: 'test description', - link: {to: '/', content: 'Home'}, - action: { - onClick: noop, - content: 'New', - }, - badge: { - content: 'flashy new home card', - status: 'new' as 'new', - }, -}; -const userProps = { - actions, - name: 'Andrew Musgrave', - detail: 'FED', - initials: 'am', - open: true, - onToggle: noop, - message, -}; describe('', () => { - it('mounts', () => { - const user = mountWithAppProvider(); - - expect(user).toBeTruthy(); - }); - - it('constructs activatorContent and passes it down to menu', () => { - const user = mountWithAppProvider(); - - expect(returnMenuProp(user, 'activatorContent')).toBeTruthy(); - }); - - it('passes the open prop down to menu', () => { - const user = mountWithAppProvider(); - - expect(returnMenuProp(user, 'open')).toBe(true); - }); - - it('passes the actions prop down to menu', () => { - const user = mountWithAppProvider(); - - expect(returnMenuProp(user, 'actions')).toBe(actions); - }); - - it('passes the message prop down to menu', () => { - const user = mountWithAppProvider(); - - expect(returnMenuProp(user, 'message')).toBe(message); - }); - - describe('onToggle', () => { - it('passes the onToggle prop down to menu as onOpen', () => { - const user = mountWithAppProvider(); + describe('', () => { + const userMenuProps: UserMenuProps = { + actions: [{items: [{icon: 'view'}]}], + name: '', + initials: '', + open: false, + onToggle: noop, + }; + + it('renders with the given props', () => { + const userMenu = mountWithAppProvider( + + + , + ); + expect(userMenu.find(UserMenuComponent).props()).toEqual(userMenuProps); + }); - expect(returnMenuProp(user, 'onOpen')).toBe(noop); + it('renders with the given props when in mobile view but no mobile props are available', () => { + const userMenu = mountWithAppProvider( + + + , + ); + expect(userMenu.find(UserMenuComponent).props()).toEqual(userMenuProps); }); - it('passes the onToggle prop down to menu as onClose', () => { - const user = mountWithAppProvider(); + it('renders with the given props when mobile props are available but not in mobile view', () => { + const mobileUserMenuProps = {...userMenuProps, initials: 'JD'}; + const userMenu = mountWithAppProvider( + + + , + ); + expect(userMenu.find(UserMenuComponent).props()).toEqual(userMenuProps); + }); - expect(returnMenuProp(user, 'onClose')).toBe(noop); + it('renders with the mobile props when available and in mobile view', () => { + const mobileUserMenuProps = {...userMenuProps, initials: 'JD'}; + const userMenu = mountWithAppProvider( + + + , + ); + expect(userMenu.find(UserMenuComponent).props()).toEqual( + mobileUserMenuProps, + ); }); }); }); - -function returnMenuProp(wrapper: ReactWrapper, prop: string) { - return wrapper.find(Menu).prop(prop); -} diff --git a/src/components/TopBar/components/index.ts b/src/components/TopBar/components/index.ts index 2a216484a98..ef4d0e30660 100644 --- a/src/components/TopBar/components/index.ts +++ b/src/components/TopBar/components/index.ts @@ -1,5 +1,10 @@ export {default as Search, Props as SearchProps} from './Search'; export {default as SearchField, Props as SearchFieldProps} from './SearchField'; -export {default as UserMenu, Props as UserProps} from './UserMenu'; +export { + default as UserMenu, + Props as UserProps, + Provider as UserMenuProvider, + Modifier as UserMenuModifier, +} from './UserMenu'; export {default as Menu, Props as MenuProps} from './Menu'; export {MessageProps} from './Menu/components'; diff --git a/src/components/TopBar/index.ts b/src/components/TopBar/index.ts index aca8b20bc68..9c7ba5de9d2 100644 --- a/src/components/TopBar/index.ts +++ b/src/components/TopBar/index.ts @@ -1,5 +1,11 @@ import TopBar from './TopBar'; export {Props} from './TopBar'; -export {UserProps, SearchFieldProps, MessageProps} from './components'; +export { + UserProps, + SearchFieldProps, + MessageProps, + UserMenuModifier, + UserMenuProvider, +} from './components'; export default TopBar;