Dropdown
Menu of selectable items triggered by a button.
Installation
$
npx @309-thingspire/ui@latest add dropdownUsage
import { Dropdown } from "@/components/dropdown/dropdown"<Dropdown />Examples
Live preview rendered from Dropdown.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React, { useId, useMemo, useState } from 'react';
import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import {
IconArrowDownSLine,
IconArrowRightSLine,
IconArrowUpSLine,
IconCheckLine,
IconFileLine,
IconHashtag,
IconSettingsLine,
} from '../icons';
import { Checkbox } from '../Checkbox/Checkbox';
import type {
DropdownItem,
DropdownItemType,
DropdownMenuWidth,
DropdownProps,
DropdownSelectMode,
DropdownSize,
DropdownVariant,
DropdownVisualState,
} from './Dropdown.types';
const palette = colors.primitive.palette;
const textBase = colors.semantic.theme.text.base;
const MENU_WIDTH_MAP: Record<DropdownMenuWidth, number> = {
compact: spacing.scale['160'] + spacing.scale['20'],
regular: spacing.scale['224'] + spacing.scale['16'],
wide: spacing.scale['320'],
};
const DEFAULT_ITEMS: DropdownItem[] = [
{ id: 'option-1', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-2', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-3', label: 'Dropdown', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-4', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-5', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-6', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-7', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
{ id: 'option-8', label: 'Option', caption: 'Caption', supportText: '@support', badgeLabel: 'Pro', avatarText: 'B' },
];
function resolveVisualState(
forcedState: DropdownVisualState | undefined,
disabled: boolean,
hovered: boolean,
focused: boolean,
): DropdownVisualState {
if (disabled || forcedState === 'disabled') {
return 'disabled';
}
if (forcedState && forcedState !== 'default') {
return forcedState;
}
if (focused) {
return 'focus';
}
if (hovered) {
return 'hover';
}
return 'default';
}
function toTypographyStyle(token: {
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
}) {
return {
fontFamily: token.fontFamily,
fontSize: token.fontSize,
fontWeight: token.fontWeight,
lineHeight: `${token.lineHeight}px`,
letterSpacing: `${token.letterSpacing}px`,
};
}
function getRowHeight(variant: DropdownVariant): number {
if (variant === 'extended') {
return spacing.scale['52'];
}
return spacing.scale['36'];
}
function getAvatarSize(variant: DropdownVariant, size: DropdownSize): number {
if (variant === 'extended' && size === 'lg') {
return spacing.scale['32'];
}
return spacing.scale['20'];
}
function shouldShowLeadingIcon(variant: DropdownVariant, item: DropdownItem): boolean {
if (typeof item.showLeadingIcon === 'boolean') {
return item.showLeadingIcon;
}
return true;
}
function shouldShowBadge(variant: DropdownVariant, item: DropdownItem): boolean {
if (typeof item.showBadge === 'boolean') {
return item.showBadge;
}
if (variant === 'select' || variant === 'extended' || variant === 'base') {
return true;
}
return false;
}
function shouldShowToggle(variant: DropdownVariant, item: DropdownItem): boolean {
if (typeof item.showToggle === 'boolean') {
return item.showToggle;
}
return variant === 'base' || variant === 'extended';
}
function shouldShowTailIcon(variant: DropdownVariant, item: DropdownItem): boolean {
if (typeof item.showTailIcon === 'boolean') {
return item.showTailIcon;
}
return variant === 'base' || variant === 'extended';
}
function Chevron({ open, disabled }: { open: boolean; disabled: boolean }) {
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['20'],
height: spacing.scale['20'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{open ? (
<IconArrowUpSLine
aria-hidden
style={{
width: spacing.scale['16'],
height: spacing.scale['16'],
display: 'block',
opacity: disabled ? 0.5 : 1,
}}
/>
) : (
<IconArrowDownSLine
aria-hidden
style={{
width: spacing.scale['16'],
height: spacing.scale['16'],
display: 'block',
opacity: disabled ? 0.5 : 1,
}}
/>
)}
</span>
);
}
function CheckMark({ disabled }: { disabled: boolean }) {
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['20'],
height: spacing.scale['20'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconCheckLine
aria-hidden
style={{
width: '100%',
height: '100%',
display: 'block',
opacity: disabled ? 0.4 : 1,
}}
/>
</span>
);
}
export function Dropdown({
id,
className,
style,
label = 'Dropdown',
variant = 'select',
state,
selectMode = 'single',
itemType = 'default',
size = 'md',
width = 'regular',
position = 'left',
open,
defaultOpen = false,
disabled = false,
showScrollbar = false,
items,
selectedIds: controlledSelectedIds,
defaultSelectedIds = [],
onSelectedIdsChange,
onOpenChange,
onItemClick,
}: DropdownProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const [uncontrolledSelectedIds, setUncontrolledSelectedIds] = useState<string[]>(defaultSelectedIds);
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
const [triggerFocused, setTriggerFocused] = useState(false);
const menuId = useId();
const isOpen = open ?? uncontrolledOpen;
const selectedIds = controlledSelectedIds ?? uncontrolledSelectedIds;
const normalizedItems = useMemo(() => {
if (!items || items.length === 0) {
return DEFAULT_ITEMS;
}
return items;
}, [items]);
const menuWidth = MENU_WIDTH_MAP[width];
const menuRole = variant === 'select' ? 'listbox' : 'menu';
const optionRole = variant === 'select' ? 'option' : 'menuitem';
const componentDisabled = disabled || state === 'disabled';
const setOpenState = (nextOpen: boolean) => {
if (open === undefined) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
const setSelectedState = (nextSelected: string[]) => {
if (controlledSelectedIds === undefined) {
setUncontrolledSelectedIds(nextSelected);
}
onSelectedIdsChange?.(nextSelected);
};
const handleTriggerClick = () => {
if (componentDisabled) {
return;
}
setOpenState(!isOpen);
};
const handleItemSelection = (item: DropdownItem, itemDisabled: boolean) => {
if (itemDisabled) {
return;
}
onItemClick?.(item);
if (variant !== 'select') {
return;
}
if (selectMode === 'multiple') {
const nextSelected = selectedIds.includes(item.id)
? selectedIds.filter((idValue) => idValue !== item.id)
: [...selectedIds, item.id];
setSelectedState(nextSelected);
return;
}
setSelectedState([item.id]);
setOpenState(false);
};
const triggerVisualState = resolveVisualState(state, componentDisabled, false, triggerFocused);
const triggerDisabled = triggerVisualState === 'disabled';
return (
<div
id={id}
className={className}
style={{
position: 'relative',
display: 'inline-flex',
flexDirection: 'column',
alignItems: position === 'right' ? 'flex-end' : 'flex-start',
gap: spacing.scale['8'],
...style,
}}
>
<button
type="button"
aria-haspopup={menuRole}
aria-expanded={isOpen}
aria-controls={menuId}
disabled={triggerDisabled}
onClick={handleTriggerClick}
onFocus={() => setTriggerFocused(true)}
onBlur={() => setTriggerFocused(false)}
style={{
minHeight: spacing.scale['40'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: position === 'right' ? 'flex-end' : 'center',
gap: spacing.scale['4'],
paddingInline: spacing.scale['12'],
paddingBlock: spacing.scale['10'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['3'],
borderRadius: radius.scale.xl,
backgroundColor: palette.base.white,
boxShadow: triggerFocused ? shadows.focusRing.light.css : 'none',
color: textBase.staticDark,
cursor: triggerDisabled ? 'not-allowed' : 'pointer',
boxSizing: 'border-box',
}}
>
<span
style={{
...toTypographyStyle(typography.scale.captionL.medium),
color: triggerDisabled ? textBase.staticDarkQuaternary : textBase.staticDark,
paddingInline: spacing.scale['4'],
}}
>
{label}
</span>
<span
style={{
width: spacing.scale['20'],
height: spacing.scale['20'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Chevron open={isOpen} disabled={triggerDisabled} />
</span>
</button>
{isOpen ? (
<div
id={menuId}
role={menuRole}
aria-multiselectable={variant === 'select' && selectMode === 'multiple' ? true : undefined}
style={{
position: 'absolute',
top: spacing.scale['40'] + spacing.scale['8'],
[position === 'right' ? 'right' : 'left']: spacing.scale['0'],
width: menuWidth,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: spacing.scale['0'],
paddingBlock: spacing.scale['4'],
paddingInline: spacing.scale['0'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['3'],
borderRadius: radius.scale.xl,
backgroundColor: palette.base.white,
boxShadow: shadows.elevation.lg.css,
boxSizing: 'border-box',
zIndex: spacing.scale['20'],
}}
>
{normalizedItems.map((item) => {
const itemHovered = hoveredItemId === item.id;
const itemFocused = focusedItemId === item.id;
const forcedState = item.state ?? state;
const itemDisabled = componentDisabled || item.disabled === true;
const visualState = resolveVisualState(forcedState, itemDisabled, itemHovered, itemFocused);
const isDisabled = visualState === 'disabled';
const isSelected = selectedIds.includes(item.id);
const showLeadingIcon = shouldShowLeadingIcon(variant, item);
const showBadge = shouldShowBadge(variant, item);
const showToggle = shouldShowToggle(variant, item);
const showTailIcon = shouldShowTailIcon(variant, item);
const rowHeight = getRowHeight(variant);
const avatarSize = getAvatarSize(variant, size);
const labelColor = isDisabled ? textBase.staticDarkQuaternary : textBase.staticDark;
const subTextColor = isDisabled ? textBase.staticDarkQuaternary : textBase.staticDarkTertiary;
const overlayBackground = (() => {
if (isSelected && variant === 'select') {
if (visualState === 'hover') {
return palette.gray['2a'];
}
return palette.gray['1a'];
}
if (visualState === 'hover') {
return palette.gray['1a'];
}
return palette.base.transparent;
})();
const wrapBorderColor = visualState === 'focus' ? palette.purple['6'] : palette.base.transparent;
const wrapBoxShadow = visualState === 'focus' ? shadows.focusRing.light.css : 'none';
const labelTypography = typography.scale.captionL.regular;
const supportTypography = typography.scale.captionM.regular;
const avatarTypography = avatarSize === spacing.scale['32'] ? typography.scale.captionL.medium : typography.scale.captionM.medium;
const leadIconElement = (() => {
if (!showLeadingIcon) {
return null;
}
if (item.leadingIcon) {
return item.leadingIcon;
}
if (itemType === 'avatar') {
return (
<span
aria-hidden="true"
style={{
width: avatarSize,
height: avatarSize,
borderRadius: radius.scale.full,
backgroundColor: palette.green['8'],
opacity: isDisabled ? 0.5 : 1,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: textBase.staticWhite,
...toTypographyStyle(avatarTypography),
}}
>
{item.avatarText ?? 'B'}
</span>
);
}
if (variant === 'extended' && size === 'lg') {
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['32'],
height: spacing.scale['32'],
borderRadius: radius.scale.full,
backgroundColor: palette.blue['1'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
width: spacing.scale['16'],
height: spacing.scale['16'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconFileLine
aria-hidden
style={{
width: '100%',
height: '100%',
display: 'block',
opacity: isDisabled ? 0.4 : 1,
}}
/>
</span>
</span>
);
}
const LeadIcon = variant === 'select' ? IconHashtag : IconFileLine;
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['20'],
height: spacing.scale['20'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<LeadIcon
aria-hidden
style={{
width: '100%',
height: '100%',
display: 'block',
opacity: isDisabled ? 0.4 : 1,
}}
/>
</span>
);
})();
const tailElement = (() => {
if (variant === 'select' && selectMode === 'single' && isSelected) {
return (
<span
aria-hidden="true"
style={{
width: spacing.scale['20'],
height: spacing.scale['20'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CheckMark disabled={isDisabled} />
</span>
);
}
if (!showTailIcon) {
return null;
}
if (item.tailIcon) {
return item.tailIcon;
}
return (
<span
aria-hidden="true"
style={{
width: variant === 'base' ? spacing.scale['16'] : spacing.scale['20'],
height: variant === 'base' ? spacing.scale['16'] : spacing.scale['20'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{variant === 'base' ? (
<IconArrowRightSLine
aria-hidden
style={{
width: '100%',
height: '100%',
display: 'block',
opacity: isDisabled ? 0.4 : 1,
}}
/>
) : (
<IconSettingsLine
aria-hidden
style={{
width: '100%',
height: '100%',
display: 'block',
opacity: isDisabled ? 0.4 : 1,
}}
/>
)}
</span>
);
})();
const badgeElement = showBadge ? (
<span
aria-hidden="true"
style={{
padding: spacing.scale['2'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['2'],
paddingBlock: spacing.scale['0'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['2a'],
borderRadius: radius.scale.xs,
backgroundColor: variant === 'select' ? palette.blue['2'] : palette.base.white,
}}
>
<span
style={{
...toTypographyStyle(typography.scale.captionM.medium),
color: variant === 'select' ? palette.blue['11'] : textBase.staticDarkSecondary,
paddingInline: spacing.scale['2'],
}}
>
{item.badgeLabel ?? 'Pro'}
</span>
</span>
</span>
) : null;
const toggleElement = showToggle ? (
<span
aria-hidden="true"
style={{
padding: spacing.scale['2'],
display: 'inline-flex',
alignItems: 'center',
}}
>
<span
style={{
width: spacing.scale['28'],
height: spacing.scale['16'],
borderRadius: radius.scale.full,
paddingInline: spacing.scale['2'],
paddingBlock: spacing.scale['2'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: item.toggleActive ? 'flex-end' : 'flex-start',
backgroundColor: isDisabled
? palette.gray['2']
: item.toggleActive
? palette.green[visualState === 'hover' ? '9' : '8']
: palette.gray['5'],
boxSizing: 'border-box',
}}
>
<span
style={{
width: spacing.scale['12'],
height: spacing.scale['12'],
borderRadius: radius.scale.full,
backgroundColor: isDisabled ? palette.gray['4'] : palette.base.white,
boxShadow: isDisabled ? 'none' : shadows.elevation.xs['0'].css,
}}
/>
</span>
</span>
) : null;
const multipleSelectPrefix = variant === 'select' && selectMode === 'multiple' ? (
<span
aria-hidden="true"
style={{
padding: spacing.scale['2'],
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Checkbox
size="sm"
type="default"
checked={isSelected}
state={isDisabled ? 'disabled' : visualState}
disabled={isDisabled}
ariaLabel={`${item.label} selection`}
/>
</span>
) : null;
return (
<button
key={item.id}
type="button"
role={optionRole}
aria-selected={variant === 'select' ? isSelected : undefined}
disabled={isDisabled}
onMouseEnter={() => setHoveredItemId(item.id)}
onMouseLeave={() => setHoveredItemId((prev) => (prev === item.id ? null : prev))}
onFocus={() => setFocusedItemId(item.id)}
onBlur={() => setFocusedItemId((prev) => (prev === item.id ? null : prev))}
onClick={() => handleItemSelection(item, isDisabled)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleItemSelection(item, isDisabled);
}
}}
style={{
width: '100%',
minHeight: rowHeight,
display: 'flex',
alignItems: 'center',
gap: spacing.scale['4'],
paddingInline: spacing.scale['6'],
paddingBlock: spacing.scale['2'],
borderStyle: 'solid',
borderWidth: border.width['0'],
borderColor: palette.base.transparent,
backgroundColor: palette.base.transparent,
textAlign: 'left',
cursor: isDisabled ? 'not-allowed' : 'pointer',
boxSizing: 'border-box',
}}
>
<span
style={{
width: '100%',
minHeight: variant === 'extended' ? spacing.scale['48'] : spacing.scale['32'],
display: 'flex',
alignItems: variant === 'extended' ? 'flex-start' : 'center',
gap: spacing.scale['4'],
padding: spacing.scale['6'],
borderRadius: radius.scale.sm,
borderStyle: 'solid',
borderWidth: visualState === 'focus' ? border.width['1'] : border.width['0'],
borderColor: wrapBorderColor,
backgroundColor: overlayBackground,
boxShadow: wrapBoxShadow,
boxSizing: 'border-box',
}}
>
{multipleSelectPrefix}
{leadIconElement}
<span
style={{
flex: 1,
minWidth: spacing.scale['0'],
display: 'flex',
flexDirection: variant === 'extended' ? 'column' : 'row',
alignItems: variant === 'extended' ? 'flex-start' : 'center',
gap: spacing.scale['4'],
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['0'],
}}
>
<span
style={{
...toTypographyStyle(labelTypography),
color: labelColor,
whiteSpace: 'nowrap',
}}
>
{item.label}
</span>
{variant === 'extended' && item.caption ? (
<span
style={{
...toTypographyStyle(supportTypography),
color: subTextColor,
whiteSpace: 'nowrap',
}}
>
{item.caption}
</span>
) : null}
{variant === 'select' && item.supportText ? (
<span
style={{
...toTypographyStyle(supportTypography),
color: subTextColor,
whiteSpace: 'nowrap',
}}
>
{item.supportText}
</span>
) : null}
{variant === 'base' && item.caption ? (
<span
style={{
...toTypographyStyle(supportTypography),
color: subTextColor,
whiteSpace: 'nowrap',
}}
>
{item.caption}
</span>
) : null}
{badgeElement}
</span>
{toggleElement}
{tailElement}
</span>
</button>
);
})}
{showScrollbar ? (
<span
aria-hidden="true"
style={{
position: 'absolute',
right: -border.width['1'],
top: -border.width['1'],
bottom: -border.width['1'],
width: spacing.scale['16'],
overflow: 'hidden',
}}
>
<span
style={{
position: 'absolute',
left: '50%',
top: spacing.scale['6'],
width: spacing.scale['4'],
height: spacing.scale['112'],
borderRadius: radius.scale.full,
backgroundColor: palette.gray['2'],
transform: 'translateX(-50%)',
}}
/>
</span>
) : null}
</div>
) : null}
</div>
);
}
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)