Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 301 additions & 63 deletions app/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,320 @@
import { useState } from "react";
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import styled, { css } from "styled-components";

type MenuItem = {
label: string;
onClick?: () => void;
};

type DropdownProps = {
buttonLabel: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be the value of the default text in the dropdown

menuItems: MenuItem[];
onChange?: (index: number, label: string) => void;
};

export default function Dropdown({ buttonLabel, menuItems }: DropdownProps) {
export default function Dropdown({
buttonLabel,
menuItems,
onChange,
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const itemsRef = useRef<Array<HTMLButtonElement | null>>([]);

// default selection: buttonLabel match -> that index; else 0
const defaultIndex = useMemo(() => {
const matchIndex = menuItems.findIndex(
(item) => item.label === buttonLabel,
);
return matchIndex >= 0 ? matchIndex : 0;
}, [buttonLabel, menuItems]);

const [selectedIndex, setSelectedIndex] = useState<number>(defaultIndex);

useEffect(() => {
setSelectedIndex(defaultIndex);
}, [defaultIndex]);

const toggleMenu = () => {
setIsOpen((prev) => !prev);
const toggleMenu = () => setIsOpen((prev) => !prev);
const closeMenu = () => setIsOpen(false);

// Close on Escape anywhere
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeMenu();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);

// Close when focus leaves the container
const handleBlur: React.FocusEventHandler<HTMLDivElement> = (event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
closeMenu();
}
};

const closeMenu = () => {
setIsOpen(false);
const focusItem = (index: number) => {
const el = itemsRef.current[index];
el?.focus();
};

// When menu opens, focus selected item (or first)
useEffect(() => {
if (isOpen) {
const id = window.setTimeout(() => focusItem(selectedIndex ?? 0), 0);
return () => window.clearTimeout(id);
}
}, [isOpen, selectedIndex]);

const moveFocus = (direction: 1 | -1) => {
const len = menuItems.length;
const current = itemsRef.current.findIndex(
(el) => el === document.activeElement,
);
const base = current === -1 ? selectedIndex : current;
const nextIndex = (base + direction + len) % len;
focusItem(nextIndex);
};

const menuId = "dropdown-menu";

return (
<div className="relative inline-block text-left">
<div>
<button
type="button"
className="inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
id="menu-button"
aria-expanded={isOpen}
aria-haspopup="true"
onClick={toggleMenu}
>
{buttonLabel}
<svg
className="-mr-1 size-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
/>
</svg>
</button>
</div>

{isOpen && (
<div
className="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
tabIndex={-1}
>
<div className="py-1" role="none">
{menuItems.map((item, index) => {
return (
<button
key={index}
type="button"
className="block w-full px-4 py-2 text-left text-sm text-gray-700"
role="menuitem"
tabIndex={-1}
onClick={() => {
item.onClick?.();
closeMenu();
}}
>
{item.label}
</button>
);
})}
</div>
</div>
)}
</div>
<Container ref={containerRef} onBlur={handleBlur}>
{/* Entire header is the control */}
<HeaderButton
id="menu-button"
aria-expanded={isOpen}
aria-haspopup="true"
aria-controls={menuId}
type="button"
onClick={toggleMenu}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setIsOpen(true);
}
}}
>
<span>{menuItems[selectedIndex]?.label ?? buttonLabel}</span>
<ArrowIcon $open={isOpen}>arrow_drop_down</ArrowIcon>
</HeaderButton>

{/* Menu panel */}
<Menu
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
id={menuId}
tabIndex={-1}
$open={isOpen}
onKeyDown={(event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveFocus(1);
break;
case "ArrowUp":
event.preventDefault();
moveFocus(-1);
break;
case "Home":
event.preventDefault();
focusItem(0);
break;
case "End":
event.preventDefault();
focusItem(menuItems.length - 1);
break;
}
}}
>
<Frame role="none">
{menuItems.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<ItemButton
key={index}
type="button"
role="menuitem"
tabIndex={0}
$selected={isSelected}
ref={(el) => {
itemsRef.current[index] = el;
}}
onClick={() => {
setSelectedIndex(index);
onChange?.(index, item.label);
closeMenu();
}}
>
{/* Left icon (star) */}
<StarIcon>star</StarIcon>

{/* Text */}
<LabelWrapper>
<LabelText>{item.label}</LabelText>
</LabelWrapper>
</ItemButton>
);
})}
</Frame>
</Menu>
</Container>
);
}

/* =================== Styled components =================== */

const Container = styled.div`
position: relative;
display: inline-block;
text-align: left;
`;

const HeaderButton = styled.button`
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
background: var(--white);
padding: 8px 12px;
font-size: 14px;
font-weight: 600;
color: var(--foreground);
border: 1px solid rgba(0, 0, 0, 0.15);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
cursor: pointer;

&:hover {
background: var(--light-gray);
}

&:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
`;

const arrowRotate = css<{ $open: boolean }>`
transform: rotate(${(props) => (props.$open ? 180 : 0)}deg);
`;

const ArrowIcon = styled.span<{ $open: boolean }>`
font-family: "Material Symbols Rounded";
font-size: 20px;
line-height: 1;
height: 20px;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
color: var(--black);
transition: transform 150ms ease;
${arrowRotate}
`;

const Menu = styled.div<{ $open: boolean }>`
position: absolute;
left: 0;
top: calc(100% + 8px);
z-index: 10;
width: 15rem;
display: inline-flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
background: var(--white);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow:
0 10px 15px rgba(0, 0, 0, 0.1),
0 4px 6px rgba(0, 0, 0, 0.05);
outline: none;
opacity: ${(props) => (props.$open ? 1 : 0)};
transform: scale(${(props) => (props.$open ? 1 : 0.98)});
transform-origin: top left;
transition:
opacity 120ms ease,
transform 120ms ease;
pointer-events: ${(props) => (props.$open ? "auto" : "none")};
`;

const Frame = styled.div`
width: 100%;
position: relative;
border-radius: 4px;
border: 2px solid var(--dark-gray);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 4px 2px;
text-align: left;
font-size: 10px;
color: var(--black);
`;

const ItemButton = styled.button<{ $selected?: boolean }>`
align-self: stretch;
display: flex;
align-items: center;
padding: 4px 2px;
gap: 4px;
cursor: pointer;
background: ${(props) =>
props.$selected ? "var(--light-orange)" : "transparent"};
border: 0;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
outline: none;

&:hover {
background: var(--light-gray);
}
&:focus-visible {
background: var(--light-gray);
}
`;

const StarIcon = styled.span`
font-family: "Material Symbols Rounded";
font-size: 16px;
line-height: 1;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
color: var(--black);
`;

const LabelWrapper = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
`;

const LabelText = styled.div`
flex: 1 0 0;
position: relative;
overflow: hidden;
color: var(--black);
text-overflow: ellipsis;
text-align: left;
font-family: Inter;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 16px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
`;
Loading