chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { Meta } from '@storybook/react-webpack5';
|
||||
import { useState } from 'react';
|
||||
import { Server, Cloud } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { DropdownMenu } from './DropdownMenu';
|
||||
|
||||
export default {
|
||||
component: DropdownMenu,
|
||||
title: 'Components/DropdownMenu',
|
||||
} as Meta;
|
||||
|
||||
const options = [
|
||||
{ key: 'Production', count: 12, icon: <Icon icon={Server} /> },
|
||||
{ key: 'Staging', count: 5, icon: <Icon icon={Cloud} /> },
|
||||
{ key: 'Development', count: 8 },
|
||||
];
|
||||
|
||||
export function Interactive() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label="Group"
|
||||
options={options}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
data-cy="dropdown-menu"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithBadge() {
|
||||
return (
|
||||
<DropdownMenu
|
||||
label="Group"
|
||||
options={options}
|
||||
selected="Production"
|
||||
onSelect={() => {}}
|
||||
badge="Production"
|
||||
data-cy="dropdown-menu-badge"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithoutCounts() {
|
||||
return (
|
||||
<DropdownMenu
|
||||
label="Status"
|
||||
options={[{ key: 'Online' }, { key: 'Offline' }, { key: 'Unknown' }]}
|
||||
selected={null}
|
||||
onSelect={() => {}}
|
||||
data-cy="dropdown-menu-no-counts"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { DropdownMenu } from './DropdownMenu';
|
||||
|
||||
const defaultOptions = [
|
||||
{ key: 'Docker', count: 3 },
|
||||
{ key: 'Kubernetes', count: 2 },
|
||||
];
|
||||
|
||||
function renderDropdown(
|
||||
overrides: Partial<React.ComponentProps<typeof DropdownMenu>> = {}
|
||||
) {
|
||||
const props = {
|
||||
label: 'Group',
|
||||
options: defaultOptions,
|
||||
selected: null as string | null,
|
||||
onSelect: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
...render(<DropdownMenu {...props} />),
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DropdownMenu', () => {
|
||||
test('renders the trigger button with the label', () => {
|
||||
renderDropdown({ label: 'Platform' });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Platform/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens the menu on click showing All and all options', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible();
|
||||
expect(screen.getByRole('menuitem', { name: /All/ })).toBeVisible();
|
||||
expect(screen.getByRole('menuitem', { name: /Docker/ })).toBeVisible();
|
||||
expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays counts for each option', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
const menu = screen.getByRole('menu', { name: /Group/i });
|
||||
expect(menu).toHaveTextContent('3');
|
||||
expect(menu).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
test('renders option icons when provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({
|
||||
options: [
|
||||
{ key: 'Docker', count: 3, icon: <span data-testid="docker-icon" /> },
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="docker-icon"]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders option label when provided instead of key', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({
|
||||
options: [{ key: 'docker', label: 'Docker Engine', count: 5 }],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
expect(screen.getByText('Docker Engine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows badge when badge is provided', () => {
|
||||
renderDropdown({ badge: 'Docker' });
|
||||
|
||||
const button = screen.getByRole('button', { name: /Group/i });
|
||||
expect(button).toHaveTextContent('Docker');
|
||||
});
|
||||
|
||||
test('does not show badge when badge is not provided', () => {
|
||||
renderDropdown({ badge: undefined });
|
||||
|
||||
const button = screen.getByRole('button', { name: /Group/i });
|
||||
expect(button).not.toHaveTextContent('Docker');
|
||||
});
|
||||
|
||||
test('calls onClick when trigger button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderDropdown({ onClick });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('closes the menu when clicking outside', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible();
|
||||
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('highlights the selected option', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({ selected: 'Docker' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
const dockerItem = screen.getByRole('menuitem', { name: /Docker/ });
|
||||
expect(dockerItem.className).toContain('bg-blue-2');
|
||||
});
|
||||
|
||||
test('highlights All when nothing is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({ selected: null });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
const allItem = screen.getByRole('menuitem', { name: /All/ });
|
||||
expect(allItem.className).toContain('bg-blue-2');
|
||||
});
|
||||
|
||||
test('applies custom className to trigger button', () => {
|
||||
renderDropdown({ className: 'custom-class' });
|
||||
|
||||
expect(screen.getByRole('button', { name: /Group/i })).toHaveClass(
|
||||
'custom-class'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows loading spinner and hides menu items when options is undefined', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDropdown({ options: undefined });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Group/i }));
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeVisible();
|
||||
expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies data-cy to trigger button', () => {
|
||||
renderDropdown({ 'data-cy': 'sort-by-group-button' });
|
||||
|
||||
expect(screen.getByRole('button', { name: /Group/i })).toHaveAttribute(
|
||||
'data-cy',
|
||||
'sort-by-group-button'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Menu,
|
||||
MenuButton as ReachMenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
} from '@reach/menu-button';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface DropdownOption {
|
||||
key: string;
|
||||
label?: string;
|
||||
count?: number;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
options: DropdownOption[] | undefined;
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
badge?: string | null;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
'aria-pressed'?: boolean;
|
||||
'data-cy'?: string;
|
||||
}
|
||||
|
||||
const menuListStyles =
|
||||
'mt-1 overflow-hidden rounded-lg ' +
|
||||
'shadow-[0_6px_12px_rgba(0,0,0,0.18)] ' +
|
||||
'!border !border-solid !border-gray-5 th-dark:!border-gray-8 th-highcontrast:!border-gray-7 ' +
|
||||
'bg-white th-dark:bg-gray-iron-11 th-highcontrast:bg-black';
|
||||
|
||||
const menuItemBase =
|
||||
'flex items-center gap-2 w-full border-none px-3 py-1.5 text-left text-sm whitespace-nowrap bg-transparent cursor-pointer';
|
||||
|
||||
const menuItemSelected =
|
||||
'!bg-blue-2 text-blue-8 [&[data-selected]]:!bg-blue-2 [&[data-selected]]:!text-blue-8 ' +
|
||||
'th-dark:!bg-blue-8 th-dark:text-blue-4 th-dark:[&[data-selected]]:!bg-blue-8 th-dark:[&[data-selected]]:!text-blue-4 ' +
|
||||
'th-highcontrast:!bg-blue-8 th-highcontrast:!text-white th-highcontrast:[&[data-selected]]:!bg-blue-8 th-highcontrast:[&[data-selected]]:!text-white';
|
||||
|
||||
const menuItemUnselected =
|
||||
'hover:bg-gray-3 [&[data-selected]]:!bg-gray-3 [&[data-selected]]:!text-gray-9 ' +
|
||||
'th-dark:hover:bg-gray-8 th-dark:[&[data-selected]]:!bg-gray-8 th-dark:[&[data-selected]]:!text-white ' +
|
||||
'th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black th-highcontrast:[&[data-selected]]:!bg-white th-highcontrast:[&[data-selected]]:!text-black';
|
||||
|
||||
const countBadge =
|
||||
'inline-flex items-center justify-center min-w-[1.25rem] h-5 rounded-full px-1 text-xs font-normal ' +
|
||||
'bg-gray-4 text-gray-9 ' +
|
||||
'th-dark:bg-gray-7 th-dark:text-gray-3 ' +
|
||||
'th-highcontrast:bg-blue-8 th-highcontrast:text-white';
|
||||
|
||||
export function DropdownMenu({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
badge,
|
||||
onClick,
|
||||
className,
|
||||
'aria-pressed': ariaPressed,
|
||||
'data-cy': dataCy,
|
||||
}: Props) {
|
||||
return (
|
||||
<Menu>
|
||||
<ReachMenuButton
|
||||
className={clsx('group flex gap-1', className)}
|
||||
onClick={() => onClick?.()}
|
||||
aria-pressed={ariaPressed}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
{label}
|
||||
{badge && (
|
||||
<span className="py-0.2 ml-1 rounded-md bg-blue-7 px-1 text-[10px] font-normal text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className="ml-1 h-3 w-3 self-center transition-transform group-[[aria-expanded=true]]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ReachMenuButton>
|
||||
<MenuList className={menuListStyles}>
|
||||
{typeof options === 'undefined' ? (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-3 text-sm text-gray-6">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={() => onSelect(null)}
|
||||
className={clsx(
|
||||
menuItemBase,
|
||||
!selected ? menuItemSelected : menuItemUnselected
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">All</span>
|
||||
</MenuItem>
|
||||
<div
|
||||
className="h-px bg-gray-4 th-highcontrast:bg-gray-7 th-dark:bg-gray-8"
|
||||
role="separator"
|
||||
/>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onSelect={() => onSelect(option.key)}
|
||||
className={clsx(
|
||||
menuItemBase,
|
||||
selected === option.key
|
||||
? menuItemSelected
|
||||
: menuItemUnselected
|
||||
)}
|
||||
>
|
||||
{option.icon && (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{option.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1">{option.label ?? option.key}</span>
|
||||
{option.count !== undefined && (
|
||||
<span className={countBadge}>{option.count}</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user