Dropdown
A menu that opens under a button and closes when the shopper clicks elsewhere, built from Trigger, Menu, Item and Divider parts.
import { Dropdown, DropdownProps, DropdownTriggerProps, DropdownMenuProps, DropdownItemProps } from '@salla.sa/twilight-theme-engine/components/dropdown';In plain words
A dropdown is a short list of choices that appears next to a button, like "Sort by" on a category page. Clicking or touching anywhere outside it calls your onClose.
As with the modal, your component keeps isOpen in state. Pass it to Dropdown and to Dropdown.Menu: the menu does not read it from its parent and stays hidden without it. Choosing an item does not close the menu either; close it yourself in the item's onClick.
Signature
function Dropdown(props: DropdownProps): JSX.Element // <div class="relative inline-block">
interface DropdownProps {
isOpen: boolean; // only used to decide whether a click outside calls onClose
onClose: () => void; // called on mousedown or touchstart outside
children: ReactNode;
position?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; // not read: set it on Menu
className?: string;
}
Dropdown.Trigger(props: DropdownTriggerProps) // inline-block <div>
Dropdown.Menu(props: DropdownMenuProps) // <ul role="menu">, null while closed
Dropdown.Item(props: DropdownItemProps) // <li role="menuitem">
Dropdown.Divider(props: HTMLAttributes<HTMLLIElement>) // only className is used
interface DropdownTriggerProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }
interface DropdownMenuProps extends HTMLAttributes<HTMLUListElement> {
children: ReactNode;
isOpen?: boolean; // default false
position?: DropdownProps['position']; // default 'bottom-start'
}
interface DropdownItemProps extends HTMLAttributes<HTMLLIElement> {
children: ReactNode;
href?: string; // renders a plain <a href>
onClick?: () => void;
disabled?: boolean;
active?: boolean;
}Try it live
import { useState } from 'react';
import { Dropdown } from '@salla.sa/twilight-theme-engine/components/dropdown';
const SORTS = [
{ value: 'newest', label: 'Newest' },
{ value: 'price-asc', label: 'Price: low to high' },
];
export function SortMenu({ sort, onSort }: { sort: string; onSort: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const close = () => setIsOpen(false);
return (
<Dropdown isOpen={isOpen} onClose={close}>
<Dropdown.Trigger>
<button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(!isOpen)}>
Sort
</button>
</Dropdown.Trigger>
<Dropdown.Menu isOpen={isOpen}>
{SORTS.map((option) => (
<Dropdown.Item
key={option.value}
active={option.value === sort}
onClick={() => {
onSort(option.value);
close();
}}
>
{option.label}
</Dropdown.Item>
))}
<Dropdown.Divider />
<Dropdown.Item disabled onClick={() => onSort('best-selling')}>
Best selling
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}
Example
import { useState } from 'react';
import { Dropdown } from '@salla.sa/twilight-theme-engine/components/dropdown';
const SORTS = [
{ value: 'newest', label: 'Newest' },
{ value: 'price-asc', label: 'Price: low to high' },
{ value: 'price-desc', label: 'Price: high to low' },
];
export function SortMenu({ sort, onSort }: { sort: string; onSort: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const close = () => setIsOpen(false);
return (
<Dropdown isOpen={isOpen} onClose={close}>
<Dropdown.Trigger>
<button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(!isOpen)}>
Sort
</button>
</Dropdown.Trigger>
{/* The Menu needs its own isOpen, and takes the position */}
<Dropdown.Menu isOpen={isOpen} position="bottom-end">
{SORTS.map((option) => (
<Dropdown.Item
key={option.value}
active={option.value === sort}
onClick={() => {
onSort(option.value);
close(); // items never close the menu themselves
}}
>
{option.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
}
How it behaves
Click-away comes from
useClickAwayin@uidotdev/usehooks:mousedownandtouchstartlisteners ondocumentfor as long as the dropdown is mounted. It callsonCloseonly whileisOpenis true. Clicks on the trigger are inside, so a toggling trigger works.Positions use logical classes (
top-full start-0,top-full end-0,bottom-full start-0,bottom-full end-0), sostartandendflip on right-to-left pages. The menu isabsolute z-40 min-w-[10rem], so an ancestor withoverflow: hiddenorautoclips it.An item without
hrefis an<li role="menuitem" tabIndex={0}>that runsonClickon click, Enter or Space;disabledremoves it from Tab order and ignores the click.activeonly adds the highlighted background.There is no Escape key handling, arrow-key movement or focus management, whatever the JSDoc says about keyboard navigation. Tabbing out of an open menu leaves it open.
The menu's
animate-in fade-in slide-in-from-top-1classes come from thetailwindcss-animateplugin, which the reference theme's Tailwind config does not load, so the menu appears without animation.No provider is needed and a closed menu renders nothing, so it is safe on the server. The same export is also at
@salla.sa/twilight-theme-engine/dropdown.
Gotchas
Dropdown.MenudefaultsisOpentofalseand does not read the parent's. The JSDoc example at the top of the engine'sDropdown.tsxrenders<Dropdown.Menu>without it, and that menu never appears. PassisOpen={isOpen}to the Menu.positiononDropdowntype-checks but is never read. Put it onDropdown.Menu.Selecting an item does not close the menu. Call your close function in each item's
onClick.An item with
hrefrenders a plain<a href>: itsonClickis dropped, anddisabledonly fades it (the link still navigates). It is also not the engineLink, so no locale is added, and on localhost and the preview host a root-relative path's first segment is read as a store username. For store pages, useonClickwithuseNavigate()from@salla.sa/twilight-theme-engine/providers.