Skip to content

Commit

Permalink
Merge pull request opentripplanner#753 from ibi-group/add-dropdown
Browse files Browse the repository at this point in the history
Add Dropdown to Building Blocks
  • Loading branch information
josh-willis-arcadis authored Jul 10, 2024
2 parents 4860f9d + 319c94a commit 293648a
Show file tree
Hide file tree
Showing 17 changed files with 6,610 additions and 5,133 deletions.
440 changes: 440 additions & 0 deletions __snapshots__/storybook.test.ts.snap

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions packages/building-blocks/src/dropdown/index.tsx
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;
71 changes: 71 additions & 0 deletions packages/building-blocks/src/dropdown/styled.tsx
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;
`;
2 changes: 2 additions & 0 deletions packages/building-blocks/src/index.ts
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 packages/building-blocks/src/stories/dropdown.story.tsx
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>
);
49 changes: 49 additions & 0 deletions packages/building-blocks/src/utils/dom-query.ts
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;
}
2 changes: 1 addition & 1 deletion packages/endpoints-overlay/i18n/zh_Hant.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
otpUi:
EndpointsOverlay:
coordinates: '{lat, number, ::.00000}; {lon, number, ::.00000}'
clearLocation: 移除{locationType}位置
coordinates: "{lat, number, ::.00000}; {lon, number, ::.00000}"
forgetHome: 忘記住家
forgetWork: 忘記工作
saveAsHome: 儲存為住家
Expand Down
2 changes: 1 addition & 1 deletion packages/from-to-location-picker/i18n/zh_Hant.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
otpUi:
FromToLocationPicker:
from: 從這裡
to: 前往這裡
planATrip: 規劃行程:
to: 前往這裡
Loading

0 comments on commit 293648a

Please sign in to comment.