-
Notifications
You must be signed in to change notification settings - Fork 116
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(settings): Add Dropdown component (#1373)
* 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
1 parent
29a6487
commit 8b2044d
Showing
14 changed files
with
676 additions
and
63 deletions.
There are no files selected for viewing
51 changes: 51 additions & 0 deletions
51
src/lib/viewers/controls/hooks/__tests__/useClickOutside-test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.