Skip to content

Commit

Permalink
feat(settings): Add Dropdown component (#1373)
Browse files Browse the repository at this point in the history
* feat(settings): Add Checkbox and Listbox components

* refactor(controls): Move list logic into SettingsList

* chore: pr comments

* chore: pr comments + updated tests

* chore: add unit tests

* chore: pr comments

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
Conrad Chan and mergify[bot] authored May 12, 2021
1 parent 29a6487 commit 8b2044d
Show file tree
Hide file tree
Showing 14 changed files with 676 additions and 63 deletions.
51 changes: 51 additions & 0 deletions src/lib/viewers/controls/hooks/__tests__/useClickOutside-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as React from 'react';
import noop from 'lodash/noop';
import { mount, ReactWrapper } from 'enzyme';
import useClickOutside from '../useClickOutside';

describe('useClickOutside', () => {
function TestComponent({ callback = noop }: { callback?: () => void }): JSX.Element {
const ref = React.createRef<HTMLButtonElement>();

useClickOutside(ref, callback);

return (
<div id="container">
<button ref={ref} id="test-button" type="button">
<span id="test-span">Test</span>
</button>
</div>
);
}

const getWrapper = (props: { callback?: () => void }): ReactWrapper =>
mount(
<div>
<TestComponent {...props} />
</div>,
{ attachTo: document.getElementById('test') },
);

beforeEach(() => {
document.body.innerHTML = '<div id="test"></div>';
});

test.each`
elementId | isCalled
${'test'} | ${true}
${'container'} | ${true}
${'test-button'} | ${false}
${'test-span'} | ${false}
`('should callback be called if click target is $elementId? $isCalled', ({ elementId, isCalled }) => {
const callback = jest.fn();
getWrapper({ callback });

const element: HTMLElement | null = document.getElementById(elementId);
if (element) {
element.click();
}

expect(element).toBeDefined();
expect(callback.mock.calls.length).toBe(isCalled ? 1 : 0);
});
});
19 changes: 19 additions & 0 deletions src/lib/viewers/controls/hooks/useClickOutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react';

export default function useClickOutside(element: React.RefObject<Element> | null, callback: () => void): void {
React.useEffect(() => {
const handleDocumentClick = ({ target }: MouseEvent): void => {
if (element && element.current && element.current.contains(target as Node)) {
return;
}

callback();
};

document.addEventListener('click', handleDocumentClick);

return (): void => {
document.removeEventListener('click', handleDocumentClick);
};
}, [callback, element]);
}
35 changes: 15 additions & 20 deletions src/lib/viewers/controls/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import React from 'react';
import classNames from 'classnames';
import SettingsCheckboxItem from './SettingsCheckboxItem';
import SettingsContext, { Menu, Rect } from './SettingsContext';
import SettingsDropdown from './SettingsDropdown';
import SettingsFlyout from './SettingsFlyout';
import SettingsGearToggle, { Ref as SettingsToggleRef } from './SettingsToggle';
import SettingsMenu from './SettingsMenu';
import SettingsMenuBack from './SettingsMenuBack';
import SettingsMenuItem from './SettingsMenuItem';
import SettingsRadioItem from './SettingsRadioItem';
import useClickOutside from '../hooks/useClickOutside';
import { decodeKeydown } from '../../../util';

export type Props = React.PropsWithChildren<{
Expand All @@ -33,6 +35,7 @@ export default function Settings({
setIsFocused(false);
setIsOpen(false);
}, []);
const { height, width } = activeRect || { height: 'auto', width: 'auto' };

const handleClick = (): void => {
setActiveMenu(Menu.MAIN);
Expand All @@ -59,23 +62,7 @@ export default function Settings({
event.stopPropagation();
};

React.useEffect(() => {
const handleDocumentClick = ({ target }: MouseEvent): void => {
const { current: controlsEl } = controlsElRef;

if (controlsEl && controlsEl.contains(target as Node)) {
return;
}

resetControls();
};

document.addEventListener('click', handleDocumentClick);

return (): void => {
document.removeEventListener('click', handleDocumentClick);
};
}, [resetControls]);
useClickOutside(controlsElRef, resetControls);

return (
<div
Expand All @@ -85,16 +72,24 @@ export default function Settings({
role="presentation"
{...rest}
>
<SettingsContext.Provider value={{ activeMenu, activeRect, setActiveMenu, setActiveRect }}>
<SettingsToggle ref={buttonElRef} isOpen={isOpen} onClick={handleClick} />
<SettingsFlyout isOpen={isOpen}>{children}</SettingsFlyout>
<SettingsContext.Provider value={{ activeMenu, setActiveMenu, setActiveRect }}>
<SettingsToggle
ref={buttonElRef}
className="bp-Settings-toggle"
isOpen={isOpen}
onClick={handleClick}
/>
<SettingsFlyout className="bp-Settings-flyout" height={height} isOpen={isOpen} width={width}>
{children}
</SettingsFlyout>
</SettingsContext.Provider>
</div>
);
}

Settings.CheckboxItem = SettingsCheckboxItem;
Settings.Context = SettingsContext;
Settings.Dropdown = SettingsDropdown;
Settings.Menu = SettingsMenu;
Settings.MenuBack = SettingsMenuBack;
Settings.MenuItem = SettingsMenuItem;
Expand Down
1 change: 0 additions & 1 deletion src/lib/viewers/controls/settings/SettingsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import noop from 'lodash/noop';

export type Context = {
activeMenu: Menu;
activeRect?: Rect;
setActiveMenu: (menu: Menu) => void;
setActiveRect: (activeRect: Rect) => void;
};
Expand Down
85 changes: 85 additions & 0 deletions src/lib/viewers/controls/settings/SettingsDropdown.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
@import './styles';

$bp-SettingsDropdown-spacing: 5px;

.bp-SettingsDropdown {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
color: $bdl-gray-62;
}

.bp-SettingsDropdown-flyout {
top: initial;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
flex: 1 1 auto;
padding: 8px 0;
transform: translateY(100%);
}

.bp-SettingsDropdown-button {
position: relative;
height: 34px;
margin: $bp-SettingsDropdown-spacing 0;
padding: $bp-SettingsDropdown-spacing 0 $bp-SettingsDropdown-spacing $bp-SettingsDropdown-spacing * 2;
color: #808080;
font-weight: normal;
font-size: 13px;
letter-spacing: 1px;
text-align: left;
background-color: $white;
border: 1px solid $sf-fog;
border-radius: 2px;
cursor: pointer;
transition: background-color .05s ease-in-out, border-color .05s ease-in-out;

&:hover,
&:focus {
background-color: #f7f7f7;
}

&::after {
position: absolute;
top: 15px;
right: 11px;
width: 0;
height: 0;
border-top: 3px solid $better-black;
border-right: 3px solid transparent;
border-left: 3px solid transparent;
transform: rotate(0);
transition: transform 200ms linear;
content: '';
}

&.bp-is-open {
&::after {
transform: rotate(-180deg);
}
}
}

.bp-SettingsDropdown-label {
margin: 4px 0;
}

.bp-SettingsDropdown-listitem {
display: block;
padding: $bp-SettingsDropdown-spacing 35px $bp-SettingsDropdown-spacing 15px;
color: $better-black;
line-height: 20px;
outline: none;

&.bp-is-active,
&:hover,
&:focus {
color: $dark-cerulean;
background: $hover-blue-background;
cursor: pointer;
fill: $dark-cerulean;
}
}
116 changes: 116 additions & 0 deletions src/lib/viewers/controls/settings/SettingsDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';
import classNames from 'classnames';
import uniqueId from 'lodash/uniqueId';
import SettingsFlyout from './SettingsFlyout';
import SettingsList from './SettingsList';
import useClickOutside from '../hooks/useClickOutside';
import { decodeKeydown } from '../../../util';
import './SettingsDropdown.scss';

export type ListItem = {
label: string;
value: string;
};

export type Props = {
className?: string;
label: string;
listItems: Array<ListItem>;
onSelect: (value: string) => void;
value?: string;
};

export default function SettingsDropdown({ className, label, listItems, onSelect, value }: Props): JSX.Element {
const { current: id } = React.useRef(uniqueId('bp-SettingsDropdown_'));
const buttonElRef = React.useRef<HTMLButtonElement | null>(null);
const dropdownElRef = React.useRef<HTMLDivElement | null>(null);
const listElRef = React.useRef<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false);

const handleKeyDown = (event: React.KeyboardEvent): void => {
const key = decodeKeydown(event);

if (key === 'Escape') {
setIsOpen(false);

if (buttonElRef.current) {
buttonElRef.current.focus(); // Prevent focus from falling back to the body on flyout close
}
}

if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'Escape') {
// Prevent the event from bubbling up and triggering any upstream keydown handling logic
event.stopPropagation();
}
};
const handleSelect = (selectedOption: string): void => {
setIsOpen(false);
onSelect(selectedOption);
};
const createClickHandler = (selectedOption: string) => (event: React.MouseEvent<HTMLDivElement>): void => {
handleSelect(selectedOption);

// Prevent the event from bubbling up and triggering any upstream click handling logic,
// i.e. if the dropdown is nested inside a menu flyout
event.stopPropagation();
};
const createKeyDownHandler = (selectedOption: string) => (event: React.KeyboardEvent<HTMLDivElement>): void => {
const key = decodeKeydown(event);

if (key !== 'Space' && key !== 'Enter') {
return;
}

handleSelect(selectedOption);
};

useClickOutside(dropdownElRef, () => setIsOpen(false));

return (
<div ref={dropdownElRef} className={classNames('bp-SettingsDropdown', className)}>
<div className="bp-SettingsDropdown-label" id={`${id}-label`}>
{label}
</div>
<button
ref={buttonElRef}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-labelledby={`${id}-label ${id}-button`}
className={classNames('bp-SettingsDropdown-button', { 'bp-is-open': isOpen })}
id={`${id}-button`}
onClick={(): void => setIsOpen(!isOpen)}
type="button"
>
{value}
</button>
<SettingsFlyout className="bp-SettingsDropdown-flyout" isOpen={isOpen}>
<SettingsList
ref={listElRef}
aria-labelledby={`${id}-label`}
className="bp-SettingsDropdown-list"
isActive
onKeyDown={handleKeyDown}
role="listbox"
tabIndex={-1}
>
{listItems.map(({ label: itemLabel, value: itemValue }) => {
return (
<div
key={itemValue}
aria-selected={value === itemValue}
className="bp-SettingsDropdown-listitem"
id={itemValue}
onClick={createClickHandler(itemValue)}
onKeyDown={createKeyDownHandler(itemValue)}
role="option"
tabIndex={0}
>
{itemLabel}
</div>
);
})}
</SettingsList>
</SettingsFlyout>
</div>
);
}
2 changes: 1 addition & 1 deletion src/lib/viewers/controls/settings/SettingsFlyout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
overflow-x: hidden;
overflow-y: auto; // Prevent scrollbar from showing on hover in IE/Edge
color: $fours;
font-size: 10px;
font-size: 13px;
line-height: normal;
background-color: $white;
border-radius: 2px;
Expand Down
13 changes: 9 additions & 4 deletions src/lib/viewers/controls/settings/SettingsFlyout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import React from 'react';
import classNames from 'classnames';
import SettingsContext from './SettingsContext';
import './SettingsFlyout.scss';

export type Props = React.PropsWithChildren<{
className?: string;
height?: number | string;
isOpen: boolean;
width?: number | string;
}>;

export default function SettingsFlyout({ children, className, isOpen }: Props): JSX.Element {
export default function SettingsFlyout({
children,
className,
height = 'auto',
isOpen,
width = 'auto',
}: Props): JSX.Element {
const [isTransitioning, setIsTransitioning] = React.useState(false);
const flyoutElRef = React.useRef<HTMLDivElement>(null);
const { activeRect } = React.useContext(SettingsContext);
const { height, width } = activeRect || { height: 'auto', width: 'auto' };

React.useEffect(() => {
const { current: flyoutEl } = flyoutElRef;
Expand Down
Loading

0 comments on commit 8b2044d

Please sign in to comment.