Merge pull request #199 from sharetribe/menu

Menu
This commit is contained in:
Vesa Luusua 2017-06-01 23:43:03 +03:00 committed by GitHub
commit 2b99ab4407
22 changed files with 568 additions and 48 deletions

View file

@ -84,7 +84,7 @@
border-width: 0px;
/* Font configuration */
text-decoration: underline;
text-decoration: none;
/* Hovers */
&:enabled {
@ -93,6 +93,7 @@
&:enabled:hover,
&:enabled:active {
background-color: transparent;
text-decoration: underline;
}
&:disabled {
background-color: transparent;

View file

@ -1,17 +1,5 @@
.container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 50px;
border-top: solid 1px #f7f7f7;
}
.container:hover {
background-color: #f7f7f7;
cursor: pointer;
}
.openIndicator {
margin-left: auto;
margin-right: 1em;
.root {
width: auto;
height: 100%;
position: relative;
}

View file

@ -0,0 +1,49 @@
import React from 'react';
import { InlineButton, MenuContent, MenuLabel, MenuItem } from '../../components';
import Menu from './Menu';
const noop = () => null;
const style = { padding: '24px' };
const btnStyle = { whiteSpace: 'nowrap' };
const MenuWrapper = () => {
return (
<Menu>
<MenuLabel>
<span>Menu</span>
</MenuLabel>
<MenuContent style={style}>
<MenuItem key="first item">
<InlineButton onClick={noop} style={btnStyle}>Click this</InlineButton>
</MenuItem>
<MenuItem key="second item">
<InlineButton onClick={noop} style={btnStyle}>Click this</InlineButton>
</MenuItem>
</MenuContent>
</Menu>
);
};
const MenuOnLeft = () => {
return <div style={{ width: '50px' }}><MenuWrapper /></div>;
};
export const MenuBasic = {
component: MenuOnLeft,
props: {},
group: 'navigation',
};
const MenuOnRight = () => {
return (
<div style={{ width: '50px', marginLeft: 'auto', marginRight: '36px' }}>
<MenuWrapper />
</div>
);
};
export const MenuBasicOnRight = {
component: MenuOnRight,
props: {},
group: 'navigation',
};

View file

@ -1,10 +1,152 @@
import React from 'react';
/**
* Menu is component that shows extra content when it is clicked.
* Clicking it toggles visibility of MenuContent.
*
* Example:
* <Menu>
* <MenuLabel>
* <span>Open menu</span>
* </MenuLabel>
* <MenuContent>
* <MenuItem key="first item">
* <button onClick={onClick}>Click this</button>
* </MenuItem>
* </MenuContent>
* </Menu>
*
*/
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import { MenuContent, MenuLabel } from '../../components';
import css from './Menu.css';
const Menu = () => (
<div className={css.container}>
New York, Jan 2nd Jan 4th <span className={css.openIndicator}></span>
</div>
);
const KEY_CODE_ESCAPE = 27;
const CONTENT_PLACEMENT_OFFSET = 22;
// This should work, but it doesn't <div className="foo" onClick={() => {}} role="button" />
/* eslint-disable jsx-a11y/no-static-element-interactions */
class Menu extends Component {
constructor(props) {
super(props);
this.state = { isOpen: false };
this.onBlur = this.onBlur.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.toggleOpen = this.toggleOpen.bind(this);
this.prepareChildren = this.prepareChildren.bind(this);
this.positionStyleForMenuContent = this.positionStyleForMenuContent.bind(this);
this.positionStyleForArrow = this.positionStyleForArrow.bind(this);
this.menu = null;
this.menuContent = null;
}
onBlur(event) {
// FocusEvent is fired faster than the link elements native click handler
// gets its own event. Therefore, we need to check the origin of this FocusEvent.
if (!this.menu.contains(event.relatedTarget)) {
this.setState({ isOpen: false });
}
}
onKeyDown(e) {
// Gather all escape presses to close menu
if (e.keyCode === KEY_CODE_ESCAPE) {
this.toggleOpen(false);
}
}
toggleOpen(enforcedState) {
this.setState(prevState => {
const isOpen = enforcedState != null ? enforcedState : !prevState.isOpen;
return { isOpen };
});
}
positionStyleForMenuContent() {
if (this.menu && this.menuContent) {
// Calculate wether we should show the menu to the left of the component or right
const distanceToRight = window.innerWidth - this.menu.getBoundingClientRect().right;
const menuWidth = this.menu.offsetWidth;
const contentWidthBiggerThanLabel = this.menuContent.offsetWidth - menuWidth;
return distanceToRight < contentWidthBiggerThanLabel
? { right: -1 * CONTENT_PLACEMENT_OFFSET, minWidth: menuWidth }
: { left: 0, minWidth: menuWidth };
}
return {};
}
positionStyleForArrow(isPositionedRight) {
if (this.menu) {
const menuWidth = this.menu.offsetWidth;
return isPositionedRight
? Math.floor(menuWidth / 2) + CONTENT_PLACEMENT_OFFSET
: Math.floor(menuWidth / 2);
}
return 0;
}
prepareChildren() {
if (React.Children.count(this.props.children) !== 2) {
throw new Error('Menu needs to have two children: MenuLabel and MenuContent.');
}
return React.Children.map(this.props.children, child => {
if (child.type === MenuLabel) {
// MenuLabel needs toggleOpen function
// We pass that directly so that component user doesn't need to worry about that
return React.cloneElement(child, { onToggleActive: this.toggleOpen });
} else if (child.type === MenuContent) {
// MenuContent needs some styling data (width, arrowPosition, and isOpen info)
// We pass those directly so that component user doesn't need to worry about those.
const positionStyles = this.positionStyleForMenuContent();
return React.cloneElement(child, {
arrowPosition: this.positionStyleForArrow(positionStyles.right != null),
contentRef: node => {
this.menuContent = node;
},
isOpen: this.state.isOpen,
style: { ...child.props.style, ...positionStyles },
});
} else {
throw new Error('Menu has an unknown child. Only MenuLabel and MenuContent are allowed.');
}
});
}
render() {
const { className, rootClassName } = this.props;
const rootClass = rootClassName || css.root;
const classes = classNames(rootClass, className);
const menuChildren = this.prepareChildren();
return (
<div
className={classes}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
ref={c => {
this.menu = c;
}}
>
{menuChildren}
</div>
);
}
}
/* eslint-enable jsx-a11y/no-static-element-interactions */
Menu.defaultProps = { className: null, rootClassName: '' };
const { node, string } = PropTypes;
Menu.propTypes = {
children: node.isRequired,
className: string,
rootClassName: string,
};
export default Menu;

View file

@ -1,10 +1,18 @@
import React from 'react';
import { renderDeep } from '../../util/test-helpers';
import { MenuItem, MenuLabel, MenuContent } from '../../components';
import Menu from './Menu';
describe('Menu', () => {
it('matches snapshot', () => {
const tree = renderDeep(<Menu />);
const tree = renderDeep(
<Menu>
<MenuLabel>Label</MenuLabel>
<MenuContent>
<MenuItem key="1">Menu item 1</MenuItem><MenuItem key="2">Menu item 2</MenuItem>
</MenuContent>
</Menu>
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,10 +1,29 @@
exports[`Menu matches snapshot 1`] = `
<div
className={undefined}>
New York, Jan 2nd Jan 4th
<span
className={undefined}>
</span>
className=""
onBlur={[Function]}
onKeyDown={[Function]}>
<button
className=""
onClick={[Function]}>
Label
</button>
<div
className=""
style={Object {}}>
<ul
className="">
<li
className=""
role="menuitem">
Menu item 1
</li>
<li
className=""
role="menuitem">
Menu item 2
</li>
</ul>
</div>
</div>
`;

View file

@ -0,0 +1,48 @@
@import '../../marketplace.css';
.root {
visibility: hidden;
pointer-events: none;
position: absolute;
z-index: var(--zIndexPopup);
background-color: var(--matterColorLight);
border: 1px solid var(--matterColorAnti);
box-shadow: var(--boxShadowPopup);
}
.isOpen {
visibility: visible;
pointer-events: auto;
}
.content {
display: flex;
flex-direction: column;
margin: 0;
}
/* Styles for arrow (if arrowPosition is defined) */
.arrowTop,
.arrowBelow {
content: ' ';
position: absolute;
bottom: 100%;
height: 0;
width: 0;
border: solid transparent;
pointer-events: none;
}
.arrowTop {
border-bottom-color: var(--matterColorLight);
border-width: 7px;
margin-left: -7px;
}
.arrowBelow {
border-bottom-color: var(--matterColorAnti);
border-width: 9px;
margin-left: -9px;
}

View file

@ -0,0 +1,80 @@
/**
* MenuContent is a immediate child of Menu component sibling to MenuLabel.
* Clicking MenuLabel toggles visibility of MenuContent.
*/
import React, { PropTypes } from 'react';
import classNames from 'classnames';
import { MenuItem } from '../../components';
import css from './MenuContent.css';
const MenuContent = props => {
const {
arrowPosition,
children,
className,
contentClassName,
contentRef,
isOpen,
rootClassName,
style,
} = props;
const rootClass = rootClassName || css.root;
const classes = classNames(rootClass, className, { [css.isOpen]: isOpen });
const contentClasses = classNames(contentClassName, css.content);
const arrowPositionStyle = arrowPosition && style.right != null
? { position: 'absolute', right: arrowPosition, top: 0 }
: { position: 'absolute', left: arrowPosition, top: 0 };
const arrow = arrowPosition
? <div style={arrowPositionStyle}>
<div className={css.arrowBelow} />
<div className={css.arrowTop} />
</div>
: null;
React.Children.forEach(children, child => {
if (child.type !== MenuItem) {
throw new Error('All children of MenuContent must be MenuItems.');
}
if (child.key == null) {
throw new Error('All children of MenuContent must have a "key" prop.');
}
});
return (
<div className={classes} ref={contentRef} style={style}>
{arrow}
<ul className={contentClasses}>
{children}
</ul>
</div>
);
};
MenuContent.defaultProps = {
arrowPosition: null,
className: null,
contentClassName: null,
contentRef: null,
isOpen: false,
rootClassName: '',
style: null,
};
const { bool, func, node, number, object, string } = PropTypes;
MenuContent.propTypes = {
arrowPosition: number,
children: node.isRequired,
className: string,
contentClassName: string,
contentRef: func,
isOpen: bool,
rootClassName: string,
style: object,
};
export default MenuContent;

View file

@ -0,0 +1,4 @@
.root {
height: 100%;
width: 100%;
}

View file

@ -0,0 +1,35 @@
/**
* MenuItem is part of Menu and specifically a child of MenuContent.
* MenuItems should have a 'key' prop specified.
* https://facebook.github.io/react/docs/lists-and-keys.html#keys
*
* Example:
* <MenuItem key="item 1"><a href="example.com">Click me</a><MenuItem>
*/
import React, { PropTypes } from 'react';
import classNames from 'classnames';
import css from './MenuItem.css';
const MenuItem = props => {
const { children, className, rootClassName } = props;
const rootClass = rootClassName || css.root;
const classes = classNames(rootClass, className);
return <li className={classes} role="menuitem">{children}</li>;
};
MenuItem.defaultProps = {
className: null,
rootClassName: '',
};
const { node, string } = PropTypes;
MenuItem.propTypes = {
children: node.isRequired,
className: string,
rootClassName: string,
};
export default MenuItem;

View file

@ -0,0 +1,6 @@
.root {
height: 100%;
width: 100%;
border: 0;
cursor: pointer;
}

View file

@ -0,0 +1,46 @@
/**
* MenuLabel is the only always visible part of Menu.
* Clicking it toggles visibility of MenuContent.
*/
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import css from './MenuLabel.css';
class MenuLabel extends Component {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
}
onClick() {
this.props.onToggleActive();
}
render() {
const { children, className, rootClassName } = this.props;
const rootClass = rootClassName || css.root;
const classes = classNames(rootClass, className);
return (
<button className={classes} onClick={this.onClick}>
{children}
</button>
);
}
}
/* eslint-enable jsx-a11y/no-static-element-interactions */
MenuLabel.defaultProps = { className: null, onToggleActive: null, rootClassName: '' };
const { func, node, string } = PropTypes;
MenuLabel.propTypes = {
children: node.isRequired,
className: string,
onToggleActive: func,
rootClassName: string,
};
export default MenuLabel;

View file

@ -32,6 +32,7 @@
/* logo */
.logoLink {
align-self: flex-start;
flex-shrink: 0;
display: flex;
align-items: center;
@ -92,14 +93,12 @@
}
/* Profile menu */
/* TODO This is placeholder, it needs Menu component */
.avatarLink {
.profileMenuLabel {
align-self: flex-start;
flex-shrink: 0;
display: flex;
align-items: center;
height: 100%;
cursor: pointer;
}
.avatar {
@ -107,6 +106,10 @@
height: 41px;
}
.profileMenuContent {
min-width: 276px;
}
/* Authentication */
.signupLink {
align-self: flex-start;
@ -125,3 +128,16 @@
composes: bodyFont from '../../marketplace.css';
color: var(--matterColor);
}
.logoutButton {
width: 100%;
padding: 20px 24px;
composes: bodyFont from '../../marketplace.css';
text-align: left;
white-space: nowrap;
&:focus {
outline: none;
text-decoration: underline;
}
}

View file

@ -1,6 +1,8 @@
import { fakeIntl } from '../../util/test-data';
import TopbarDesktop from './TopbarDesktop';
const noop = () => null;
export const AuthenticatedDesktopTopbar = {
component: TopbarDesktop,
props: {
@ -8,6 +10,7 @@ export const AuthenticatedDesktopTopbar = {
currentUserHasListings: true,
name: 'John Doe',
intl: fakeIntl,
onLogout: noop,
},
group: 'navigation',
};

View file

@ -1,13 +1,29 @@
import React, { PropTypes } from 'react';
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { Avatar, NamedLink } from '../../components';
import {
Avatar,
InlineButton,
Menu,
MenuLabel,
MenuContent,
MenuItem,
NamedLink,
} from '../../components';
import logo from './images/saunatime-logo.png';
import css from './TopbarDesktop.css';
const TopbarDesktop = props => {
const { className, rootClassName, currentUserHasListings, intl, isAuthenticated, name } = props;
const {
className,
rootClassName,
currentUserHasListings,
intl,
isAuthenticated,
name,
onLogout,
} = props;
const rootClass = rootClassName || css.root;
const classes = classNames(rootClass, className);
@ -29,9 +45,19 @@ const TopbarDesktop = props => {
</NamedLink>
: null;
// TODO This is just a placeholder
const profileMenu = isAuthenticated
? <div className={css.avatarLink}> <Avatar className={css.avatar} name={name} /></div>
? <Menu>
<MenuLabel className={css.profileMenuLabel}>
<Avatar className={css.avatar} name={name} />
</MenuLabel>
<MenuContent className={css.profileMenuContent}>
<MenuItem key="logout">
<InlineButton className={css.logoutButton} onClick={onLogout}>
<FormattedMessage id="TopbarDesktop.logout" />
</InlineButton>
</MenuItem>
</MenuContent>
</Menu>
: null;
const signupLink = isAuthenticated
@ -47,7 +73,7 @@ const TopbarDesktop = props => {
</NamedLink>;
return (
<div className={classes}>
<nav className={classes}>
<NamedLink className={css.logoLink} name="LandingPage">
<img
className={css.logo}
@ -66,18 +92,19 @@ const TopbarDesktop = props => {
{profileMenu}
{signupLink}
{loginLink}
</div>
</nav>
);
};
TopbarDesktop.defaultProps = { name: '', className: null, rootClassName: '' };
const { bool, string } = PropTypes;
const { bool, func, string } = PropTypes;
TopbarDesktop.propTypes = {
className: string,
currentUserHasListings: bool.isRequired,
isAuthenticated: bool.isRequired,
onLogout: func.isRequired,
name: string,
rootClassName: string,

View file

@ -6,12 +6,20 @@ import routesConfiguration from '../../routesConfiguration';
import { flattenRoutes } from '../../util/routes';
import TopbarDesktop from './TopbarDesktop';
const noop = () => null;
describe('TopbarDesktop', () => {
it('data matches snapshot', () => {
const flattenedRoutes = flattenRoutes(routesConfiguration);
const tree = renderDeep(
<RoutesProvider flattenedRoutes={flattenedRoutes}>
<TopbarDesktop isAuthenticated currentUserHasListings name="John Doe" intl={fakeIntl} />
<TopbarDesktop
isAuthenticated
currentUserHasListings
name="John Doe"
intl={fakeIntl}
onLogout={noop}
/>
</RoutesProvider>
);
expect(tree).toMatchSnapshot();

View file

@ -1,5 +1,5 @@
exports[`TopbarDesktop data matches snapshot 1`] = `
<div
<nav
className="">
<a
className="NamedLink_active"
@ -45,12 +45,35 @@ exports[`TopbarDesktop data matches snapshot 1`] = `
</span>
</a>
<div
className={undefined}>
<img
alt="John Doe"
className=""
onBlur={[Function]}
onKeyDown={[Function]}>
<button
className=""
src="wireframeAvatar.svg" />
onClick={[Function]}>
<img
alt="John Doe"
className=""
src="wireframeAvatar.svg" />
</button>
<div
className=""
style={Object {}}>
<ul
className="">
<li
className=""
role="menuitem">
<button
className=""
onClick={[Function]}>
<span>
Log out
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</nav>
`;

View file

@ -23,6 +23,9 @@ import LocationAutocompleteInput from './LocationAutocompleteInput/LocationAutoc
import Map from './Map/Map';
import MapPanel from './MapPanel/MapPanel';
import Menu from './Menu/Menu';
import MenuContent from './MenuContent/MenuContent';
import MenuItem from './MenuItem/MenuItem';
import MenuLabel from './MenuLabel/MenuLabel';
import Modal from './Modal/Modal';
import ModalInMobile from './ModalInMobile/ModalInMobile';
import NamedLink from './NamedLink/NamedLink';
@ -71,6 +74,9 @@ export {
Map,
MapPanel,
Menu,
MenuContent,
MenuItem,
MenuLabel,
Modal,
ModalInMobile,
NamedLink,

View file

@ -141,6 +141,7 @@ class TopbarComponent extends Component {
intl={intl}
isAuthenticated={isAuthenticated}
name={name}
onLogout={this.handleLogout}
/>
</div>
<Modal

View file

@ -7,6 +7,7 @@ import * as DateInput from './components/DateInput/DateInput.example';
import * as EditListingWizard from './components/EditListingWizard/EditListingWizard.example';
import * as ListingCard from './components/ListingCard/ListingCard.example';
import * as Map from './components/Map/Map.example';
import * as Menu from './components/Menu/Menu.example';
import * as Modal from './components/Modal/Modal.example';
import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example';
import * as NamedLink from './components/NamedLink/NamedLink.example';
@ -61,6 +62,7 @@ export {
LocationAutocompleteInput,
LoginForm,
Map,
Menu,
Modal,
ModalInMobile,
NamedLink,

View file

@ -26,6 +26,13 @@
--boxShadowLight: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
--boxShadowPopup: 0 8px 16px 0 rgba(0, 0, 0, 0.3);
--boxShadowPopupLight: 0 3px 6px 0 rgba(0, 0, 0, 0.3);
/* z-index base levels */
/* small popups on UI should use z-indexes above 50 */
--zIndexPopup: 50;
/* modals and UI overlays should use z-indexes above 100 */
--zIndexModal: 100;
}
/* FONTS */

View file

@ -187,6 +187,7 @@
"TopbarDesktop.createListing": "+ Add your sauna",
"TopbarDesktop.inbox": "Inbox",
"TopbarDesktop.login": "Log in",
"TopbarDesktop.logout": "Log out",
"TopbarDesktop.logo": "Logo",
"TopbarDesktop.signup": "Sign up",
"TopbarMobileMenu.inboxLink": "Inbox",