forked from opentripplanner/otp-ui
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request opentripplanner#753 from ibi-group/add-dropdown
Add Dropdown to Building Blocks
- Loading branch information
Showing
17 changed files
with
6,610 additions
and
5,133 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
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,139 @@ | ||
import React, { | ||
HTMLAttributes, | ||
KeyboardEvent, | ||
useCallback, | ||
useEffect, | ||
useRef, | ||
useState | ||
} from "react"; | ||
|
||
import { getNextSibling, getPreviousSibling } from "../utils/dom-query"; | ||
import { DropdownButton, DropdownMenu, DropdownWrapper } from "./styled"; | ||
|
||
export interface Props extends HTMLAttributes<HTMLElement> { | ||
alignMenuLeft?: boolean; | ||
buttonStyle?: React.CSSProperties; | ||
label?: string; | ||
listLabel?: string; | ||
text?: JSX.Element | string; | ||
nav?: boolean; | ||
} | ||
|
||
/** | ||
* Renders a dropdown menu. By default, only a passed "text" is rendered. If clicked, | ||
* a floating div is rendered below the "text" with list contents inside. Clicking anywhere | ||
* outside the floating div will close the dropdown. | ||
*/ | ||
const Dropdown = ({ | ||
alignMenuLeft, | ||
children, | ||
className, | ||
id, | ||
label, | ||
listLabel, | ||
text, | ||
buttonStyle | ||
}: Props): JSX.Element => { | ||
const [open, setOpen] = useState(false); | ||
|
||
const containerRef = useRef<HTMLSpanElement>(null); | ||
|
||
const toggleOpen = useCallback(() => setOpen(!open), [open, setOpen]); | ||
|
||
// Argument for document.querySelectorAll to target focusable elements. | ||
const queryId = `#${id} button, #${id}-label`; | ||
|
||
const isList = Array.isArray(children) | ||
? children.every( | ||
child => React.isValidElement(child) && child.type === "li" | ||
) | ||
: React.isValidElement(children) && children.type === "li"; | ||
|
||
// Adding document event listeners allows us to close the dropdown | ||
// when the user interacts with any part of the page that isn't the dropdown | ||
useEffect(() => { | ||
const handleExternalAction = (e: Event): void => { | ||
if (!containerRef?.current?.contains(e.target as HTMLElement)) { | ||
setOpen(false); | ||
} | ||
}; | ||
document.addEventListener("mousedown", handleExternalAction); | ||
document.addEventListener("focusin", handleExternalAction); | ||
document.addEventListener("keydown", handleExternalAction); | ||
return () => { | ||
document.removeEventListener("mousedown", handleExternalAction); | ||
document.removeEventListener("focusin", handleExternalAction); | ||
document.removeEventListener("keydown", handleExternalAction); | ||
}; | ||
}, [containerRef]); | ||
|
||
const handleKeyDown = useCallback( | ||
(e: KeyboardEvent): void => { | ||
const element = e.target as HTMLElement; | ||
switch (e.key) { | ||
case "ArrowUp": | ||
e.preventDefault(); | ||
getPreviousSibling(queryId, element)?.focus(); | ||
break; | ||
case "ArrowDown": | ||
e.preventDefault(); | ||
getNextSibling(queryId, element)?.focus(); | ||
break; | ||
case "Escape": | ||
setOpen(false); | ||
break; | ||
case " ": | ||
case "Enter": | ||
e.preventDefault(); | ||
element.click(); | ||
if (element.id === `${id}-label` || element.id === `${id}-wrapper`) { | ||
toggleOpen(); | ||
} | ||
break; | ||
default: | ||
} | ||
}, | ||
[id, toggleOpen] | ||
); | ||
|
||
return ( | ||
<DropdownWrapper | ||
className={className} | ||
id={`${id}-wrapper`} | ||
onKeyDown={handleKeyDown} | ||
ref={containerRef} | ||
> | ||
<DropdownButton | ||
// Only set aria-controls when the dropdown is open | ||
// (otherwise, assistive technologies may not announce the dropdown correctly). | ||
aria-controls={open ? id : undefined} | ||
aria-expanded={open} | ||
aria-haspopup="listbox" | ||
aria-label={label} | ||
id={`${id}-label`} | ||
onClick={toggleOpen} | ||
style={buttonStyle} | ||
title={label} | ||
> | ||
<span>{text}</span> | ||
<span className="caret" role="presentation" /> | ||
</DropdownButton> | ||
{open && ( | ||
<DropdownMenu | ||
as={isList && "ul"} | ||
aria-label={listLabel} | ||
aria-labelledby={listLabel ? undefined : `${id}-label`} | ||
id={id} | ||
onClick={toggleOpen} | ||
alignLeft={alignMenuLeft} | ||
role={isList && "list"} | ||
tabIndex={-1} | ||
> | ||
{children} | ||
</DropdownMenu> | ||
)} | ||
</DropdownWrapper> | ||
); | ||
}; | ||
|
||
export default Dropdown; |
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,71 @@ | ||
import styled from "styled-components"; | ||
import grey from "../colors/grey"; | ||
|
||
export const DropdownButton = styled.button` | ||
background: #fff; | ||
border: 1px solid black; | ||
border-radius: 5px; | ||
color: inherit; | ||
padding: 5px 7px; | ||
transition: all 0.1s ease-in-out; | ||
span.caret { | ||
border-left: 4px solid transparent; | ||
border-right: 4px solid transparent; | ||
border-top: 4px solid; | ||
color: inherit; | ||
display: inline-block; | ||
height: 0; | ||
margin-left: 5px; | ||
vertical-align: middle; | ||
width: 0; | ||
} | ||
&:hover, | ||
&[aria-expanded="true"] { | ||
background: ${grey[50]}; | ||
color: black; | ||
cursor: pointer; | ||
} | ||
`; | ||
|
||
export const DropdownMenu = styled.div<{ alignLeft?: boolean }>` | ||
background-clip: padding-box; | ||
background-color: #fff; | ||
border-radius: 4px; | ||
border: 1px solid rgba(0, 0, 0, 0.15); | ||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); | ||
color: #333; | ||
list-style: none; | ||
margin: 2px 0 0; | ||
min-width: 160px; | ||
padding: 5px 0; | ||
position: absolute; | ||
${props => (props.alignLeft ? "left: 0;" : "right: 0;")} | ||
top: 100%; | ||
width: 100%; | ||
z-index: 1000; | ||
hr { | ||
margin: 0; | ||
padding: 0; | ||
} | ||
a, | ||
button { | ||
background: transparent; | ||
border: none; | ||
cursor: pointer; | ||
padding: 5px 15px; | ||
text-align: start; | ||
width: 100%; | ||
&:hover { | ||
background: ${grey[50]}; | ||
} | ||
} | ||
`; | ||
|
||
export const DropdownWrapper = styled.span<{ pullRight?: boolean }>` | ||
float: ${props => (props.pullRight ? "right" : "left")}; | ||
position: relative; | ||
`; |
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 |
---|---|---|
@@ -1,5 +1,7 @@ | ||
import blue from "./colors/blue"; | ||
import red from "./colors/red"; | ||
import grey from "./colors/grey"; | ||
import Dropdown from "./dropdown"; | ||
|
||
export { Dropdown }; | ||
export default { blue, red, grey }; |
105 changes: 105 additions & 0 deletions
105
packages/building-blocks/src/stories/dropdown.story.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,105 @@ | ||
import React from "react"; | ||
import { ComponentMeta } from "@storybook/react"; | ||
import styled from "styled-components"; | ||
import Dropdown from "../dropdown"; | ||
import blue from "../colors/blue"; | ||
|
||
const options = [ | ||
{ value: "1", label: "One" }, | ||
{ value: "2", label: "Two" }, | ||
{ value: "3", label: "Three" } | ||
]; | ||
|
||
const NavItemWrapper = styled.div` | ||
align-items: center; | ||
background: ${blue[900]}; | ||
display: flex; | ||
min-height: 50px; | ||
padding: 0 10px; | ||
position: relative; | ||
width: 100%; | ||
.navBarItem { | ||
position: static; | ||
& > button { | ||
background: transparent; | ||
border: none; | ||
color: white; | ||
padding: 15px; | ||
@media (max-width: 768px) { | ||
padding: 10px; | ||
} | ||
&:hover { | ||
background: rgba(0, 0, 0, 0.05); | ||
color: #ececec; | ||
} | ||
} | ||
} | ||
`; | ||
|
||
export default { | ||
title: "Building-Blocks/Dropdown", | ||
component: Dropdown | ||
} as ComponentMeta<typeof Dropdown>; | ||
|
||
export const DropdownWithLabel = (): React.ReactElement => ( | ||
<Dropdown | ||
id="dropdown-with-label" | ||
label="Dropdown with label" | ||
listLabel="Dropdown menu" | ||
text="Dropdown with label" | ||
> | ||
More content here | ||
</Dropdown> | ||
); | ||
|
||
export const DropdownWithList = (): React.ReactElement => ( | ||
<Dropdown | ||
id="dropdown-with-ul" | ||
label="Dropdown with ul" | ||
listLabel="Dropdown menu" | ||
text="Dropdown with ul" | ||
> | ||
{options.map(option => ( | ||
<li key={option.value} value={option.value}> | ||
<button type="button">{option.label}</button> | ||
</li> | ||
))} | ||
</Dropdown> | ||
); | ||
|
||
export const DropdownWithListAlignMenuLeft = (): React.ReactElement => ( | ||
<Dropdown | ||
alignMenuLeft | ||
id="dropdown-with-ul" | ||
label="Dropdown with ul" | ||
listLabel="Dropdown menu" | ||
text="Dropdown with ul" | ||
> | ||
{options.map(option => ( | ||
<li key={option.value} value={option.value}> | ||
<button type="button">{option.label}</button> | ||
</li> | ||
))} | ||
</Dropdown> | ||
); | ||
|
||
export const DropdownAsOtprrNavItem = (): React.ReactElement => ( | ||
<NavItemWrapper> | ||
<Dropdown | ||
className="navBarItem" | ||
id="dropdown-navbar" | ||
label="Dropdown navbar" | ||
listLabel="Dropdown within navbar" | ||
text="Icon" | ||
> | ||
{options.map(option => ( | ||
<button key={option.value} type="button"> | ||
{option.label} | ||
</button> | ||
))} | ||
</Dropdown> | ||
</NavItemWrapper> | ||
); |
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,49 @@ | ||
function getEntries(query: string) { | ||
const entries = Array.from(document.querySelectorAll(query)); | ||
const firstElement = entries[0]; | ||
const lastElement = entries[entries.length - 1]; | ||
|
||
return { entries, firstElement, lastElement }; | ||
} | ||
|
||
/** | ||
* Helper method to find the next focusable sibling element relative to the | ||
* specified element. | ||
* | ||
* @param {string} query - Argument that gets passed to document.querySelectorAll | ||
* @param {HTMLElement} element - Specified element (e.target) | ||
* @returns {HTMLElement} - element to be focused | ||
*/ | ||
export function getNextSibling( | ||
query: string, | ||
element: EventTarget | ||
): HTMLElement { | ||
const { entries, firstElement, lastElement } = getEntries(query); | ||
|
||
if (element === lastElement) { | ||
return firstElement as HTMLElement; | ||
} | ||
const elementIndex = entries.indexOf(element as HTMLElement); | ||
return entries[elementIndex + 1] as HTMLElement; | ||
} | ||
|
||
/** | ||
* Helper method to find the previous focusable sibling element relative to the | ||
* specified element. | ||
* | ||
* @param {string} query - Argument that gets passed to document.querySelectorAll | ||
* @param {HTMLElement} element - Specified element (e.target) | ||
* @returns {HTMLElement} - element to be focused | ||
*/ | ||
export function getPreviousSibling( | ||
query: string, | ||
element: EventTarget | ||
): HTMLElement { | ||
const { entries, firstElement, lastElement } = getEntries(query); | ||
|
||
if (element === firstElement) { | ||
return lastElement as HTMLElement; | ||
} | ||
const elementIndex = entries.indexOf(element as HTMLButtonElement); | ||
return entries[elementIndex - 1] as HTMLElement; | ||
} |
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 |
---|---|---|
@@ -1,5 +1,5 @@ | ||
otpUi: | ||
FromToLocationPicker: | ||
from: 從這裡 | ||
to: 前往這裡 | ||
planATrip: 規劃行程: | ||
to: 前往這裡 |
Oops, something went wrong.