Skip to content

Commit

Permalink
feat: add DropdownMenu component (#826)
Browse files Browse the repository at this point in the history
  • Loading branch information
QingqiShi authored Mar 11, 2021
1 parent ca95814 commit 0655032
Show file tree
Hide file tree
Showing 16 changed files with 389 additions and 76 deletions.
39 changes: 23 additions & 16 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { Suspense, lazy } from 'react';
import { Switch, Route } from 'react-router-dom';
import { useMedia } from 'react-use';
import { ThemeProvider } from 'styled-components/macro';
import { GlobalStyle, TopLinearLoader, getTheme } from '@tuja/components';
import {
KeyboardFocusProvider,
GlobalStyle,
TopLinearLoader,
getTheme,
} from '@tuja/components';

const Home = lazy(() => import('views/Home'));
const AppShell = lazy(() => import('views/AppShell'));
Expand All @@ -11,21 +16,23 @@ function App() {
const isDark = useMedia('(prefers-color-scheme: dark)');

return (
<ThemeProvider
theme={getTheme(isDark ? ('dark' as const) : ('light' as const))}
>
<GlobalStyle />
<Suspense fallback={<TopLinearLoader />}>
<Switch>
<Route path="/" exact>
<Home />
</Route>
<Route>
<AppShell />
</Route>
</Switch>
</Suspense>
</ThemeProvider>
<KeyboardFocusProvider>
<ThemeProvider
theme={getTheme(isDark ? ('dark' as const) : ('light' as const))}
>
<GlobalStyle />
<Suspense fallback={<TopLinearLoader />}>
<Switch>
<Route path="/" exact>
<Home />
</Route>
<Route>
<AppShell />
</Route>
</Switch>
</Suspense>
</ThemeProvider>
</KeyboardFocusProvider>
);
}

Expand Down
9 changes: 6 additions & 3 deletions packages/components/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { addDecorator } from '@storybook/react';
import { ThemeProvider } from 'styled-components';
import { GlobalStyle } from '../src/globalStyle';
import { getTheme } from '../src/theme';
import { KeyboardFocusProvider } from '../src/hooks/useKeyboardFocus';

export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
Expand Down Expand Up @@ -36,8 +37,10 @@ addDecorator((storyFn, context) => {
}, [context.globals.theme]);

return (
<ThemeProvider theme={getTheme(context.globals.theme)}>
{storyFn()}
</ThemeProvider>
<KeyboardFocusProvider>
<ThemeProvider theme={getTheme(context.globals.theme)}>
{storyFn()}
</ThemeProvider>
</KeyboardFocusProvider>
);
});
10 changes: 8 additions & 2 deletions packages/components/src/components/controls/ButtonBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import styled, { css } from 'styled-components';
import useKeyboardFocus from '../../hooks/useKeyboardFocus';
import { v } from '../../theme';

const Button = styled.button<{ isTabFocused?: boolean; disabled?: boolean }>`
interface ButtonProps {
isTabFocused?: boolean;
}
const Button = styled.button<ButtonProps & React.ComponentProps<'button'>>`
border: 0;
border-radius: 50rem;
font-family: inherit;
Expand Down Expand Up @@ -50,6 +53,7 @@ interface ButtonBaseProps {
className?: string;
href?: string;
disabled?: boolean;
autoFocus?: boolean;
onClick?: (e: React.MouseEvent) => void;
}

Expand All @@ -58,9 +62,10 @@ function ButtonBase({
href,
disabled,
children,
autoFocus,
onClick,
}: React.PropsWithChildren<ButtonBaseProps>) {
const [ref, isTabFocused] = useKeyboardFocus();
const [ref, isTabFocused] = useKeyboardFocus(autoFocus);
return (
<Button
ref={ref}
Expand All @@ -73,6 +78,7 @@ function ButtonBase({
className={className}
isTabFocused={isTabFocused}
disabled={disabled}
autoFocus={autoFocus}
>
{children}
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Story, Meta } from '@storybook/react';
import ButtonSecondary from './ButtonSecondary';

export default {
title: 'Controls/Buttons/ButtonSecondary',
component: ButtonSecondary,
} as Meta;

const Template: Story<React.ComponentProps<typeof ButtonSecondary>> = (
args
) => <ButtonSecondary {...args} />;

export const Example = Template.bind({});
Example.args = {
children: 'Get started',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fireEvent, render } from '@testing-library/react';
import ButtonSecondary from './ButtonSecondary';

test('normal button', () => {
const handleClick = jest.fn();
const { getByText } = render(
<ButtonSecondary onClick={handleClick}>test</ButtonSecondary>
);
expect(getByText('test').tagName).toBe('BUTTON');
fireEvent.click(getByText('test'));
expect(handleClick).toHaveBeenCalled();
});

test('button as an anchor tag', () => {
const handleClick = jest.fn();
const { getByText } = render(
<ButtonSecondary href="/test" onClick={handleClick}>
test
</ButtonSecondary>
);
expect(getByText('test').tagName).toBe('A');
expect(getByText('test')).toHaveAttribute('href', '/test');
fireEvent.click(getByText('test'));
expect(handleClick).toHaveBeenCalled();
expect(handleClick.mock.calls[0][0].defaultPrevented).toBeTruthy();
});
40 changes: 40 additions & 0 deletions packages/components/src/components/controls/ButtonSecondary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import styled from 'styled-components';
import ButtonBase from './ButtonBase';
import { v } from '../../theme';

const Button = styled(ButtonBase)`
color: ${v.accentMain};
background-color: ${v.backgroundRaised};
box-shadow: ${v.shadowRaised};
transition: box-shadow 0.2s, color 0.2s, background 0.2s;
&:hover {
color: ${v.accentHover};
background-color: ${v.backgroundOverlay};
}
&:active {
background-color: ${v.backgroundOverlay};
box-shadow: ${v.shadowPressed};
}
`;

interface ButtonSecondaryProps {
href?: string;
disabled?: boolean;
onClick?: (e: React.MouseEvent) => void;
}

function ButtonSecondary({
children,
href,
disabled,
onClick,
}: React.PropsWithChildren<ButtonSecondaryProps>) {
return (
<Button href={href} onClick={onClick} disabled={disabled}>
{children}
</Button>
);
}

export default ButtonSecondary;
24 changes: 16 additions & 8 deletions packages/components/src/components/controls/DateRangeTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ test('render default', () => {

test('render date range options', () => {
const handleChange = jest.fn();

const threeMonth = new Date();
threeMonth.setMonth(threeMonth.getMonth() - 3);
threeMonth.setHours(0);
threeMonth.setMinutes(0);
threeMonth.setSeconds(0);
threeMonth.setMilliseconds(0);

const { getByText } = render(
<DateRangeTabs
maxDate={new Date(1555332950299)}
value={new Date(1607558400000)}
value={threeMonth}
onChange={handleChange}
/>
);
Expand All @@ -28,11 +36,11 @@ test('render date range options', () => {
expect(getByText('3M')).toBeDisabled();

fireEvent.click(getByText('6M'));
const date = new Date();
date.setMonth(date.getMonth() - 6);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
expect(handleChange).toHaveBeenCalledWith(date);
const sixMonth = new Date();
sixMonth.setMonth(sixMonth.getMonth() - 6);
sixMonth.setHours(0);
sixMonth.setMinutes(0);
sixMonth.setSeconds(0);
sixMonth.setMilliseconds(0);
expect(handleChange).toHaveBeenCalledWith(sixMonth);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Story, Meta } from '@storybook/react';
import DropdownMenu from './DropdownMenu';

export default {
title: 'Controls/DropdownMenu',
component: DropdownMenu,
} as Meta;

const Template: Story<React.ComponentProps<typeof DropdownMenu>> = (args) => (
<div>
<DropdownMenu {...args} />
<button>test</button>
</div>
);

export const Example = Template.bind({});
Example.args = {
value: 'a',
options: [
{ value: 'a', label: 'Test 1' },
{ value: 'b', label: 'Test 2' },
],
};

export const MenuRightAlign = Template.bind({});
MenuRightAlign.args = {
align: 'right',
value: 'a',
options: [
{ value: 'a', label: 'Test 1' },
{ value: 'b', label: 'Test 2' },
],
};
22 changes: 22 additions & 0 deletions packages/components/src/components/controls/DropdownMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { render, fireEvent } from '@testing-library/react';
import DropdownMenu from './DropdownMenu';

test('render', () => {
const handleChange = jest.fn();
const { getByText } = render(
<DropdownMenu
value="a"
options={[
{ label: 'Test 1', value: 'a' },
{ label: 'Test 2', value: 'b' },
]}
onChange={handleChange}
/>
);

fireEvent.click(getByText('Test 1'));
expect(getByText('Test 2')).toBeInTheDocument();

fireEvent.click(getByText('Test 2'));
expect(handleChange).toHaveBeenCalledWith('b');
});
86 changes: 86 additions & 0 deletions packages/components/src/components/controls/DropdownMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState, useRef, useEffect } from 'react';
import { useClickAway } from 'react-use';
import styled, { css } from 'styled-components';
import { CaretDown } from 'phosphor-react';
import ButtonBase from './ButtonBase';
import ButtonSecondary from './ButtonSecondary';
import { v } from '../../theme';

type MenuAlign = 'left' | 'right';

const Container = styled.div<{ align?: MenuAlign }>`
position: relative;
${({ align }) => align === 'right' && 'text-align: right;'}
`;

const ButtonLabel = styled.span`
margin-right: ${v.spacerXS};
`;

const Menu = styled.div<{ align?: MenuAlign }>`
position: absolute;
top: 0;
border-radius: ${v.radiusCard};
background-color: ${v.backgroundOverlay};
box-shadow: ${v.shadowOverlay};
padding: ${v.spacerXS};
${({ align }) => (align === 'right' ? 'right: 0;' : 'left: 0;')}
`;

const MenuItem = styled(ButtonBase)`
display: block;
border-radius: ${v.radiusMedia};
padding: ${v.spacerS} ${v.spacerM};
&:hover {
background-color: ${v.backgroundHover};
}
`;

interface DropdownMenuProps {
value: string;
options: { value: string; label: string }[];
align?: MenuAlign;
onChange?: (value: string) => void;
}

function DropdownMenu({ align, value, options, onChange }: DropdownMenuProps) {
const [showOverlay, setShowOverlay] = useState(false);

const menuRef = useRef<HTMLDivElement>(null);
useClickAway(menuRef, () => setShowOverlay(false), [
'mousedown',
'touchstart',
'focusin',
]);

return (
<Container align={align}>
<ButtonSecondary onClick={() => setShowOverlay(true)}>
<ButtonLabel>
{options.find((o) => o.value === value)?.label}
</ButtonLabel>
<CaretDown weight="bold" />
</ButtonSecondary>
{showOverlay && (
<Menu ref={menuRef} align={align}>
{options.map((option, i) => (
<MenuItem
key={`menu-option-${i}`}
autoFocus={i === 0}
onClick={() => {
onChange?.(option.value);
setShowOverlay(false);
}}
>
{option.label}
</MenuItem>
))}
</Menu>
)}
</Container>
);
}

export default DropdownMenu;
Loading

0 comments on commit 0655032

Please sign in to comment.