Use Headless UI v2 data attributes and anchor positioning for UserMenu and ProfileTabs

This commit is contained in:
Aaron Po
2026-09-12 18:18:38 -04:00
parent 5449b284cb
commit 81717eddbf
3 changed files with 49 additions and 44 deletions

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, within } from 'storybook/test';
import Navbar from './Navbar';
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
@@ -47,9 +47,10 @@ export const Authenticated: Story = {
const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
// MenuItems is anchor-positioned, which portals it outside canvasElement.
await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
await expect(screen.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(screen.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
},
};

View File

@@ -7,29 +7,36 @@ interface UserMenuProps {
}
const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
<Menu as="div" className="relative">
<Menu>
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
<MenuItems
anchor="bottom end"
transition
className="menu z-60 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl [--anchor-gap:8px] focus:outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem>
{({ focus }) => (
<Link to={`/users/${userAccountId}`} className={focus ? 'active' : ''}>
My profile
</Link>
)}
<Link
to={`/users/${userAccountId}`}
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
My profile
</Link>
</MenuItem>
<MenuItem>
{({ focus }) => (
<Link to="/dashboard" className={focus ? 'active' : ''}>
Dashboard
</Link>
)}
<Link
to="/dashboard"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Dashboard
</Link>
</MenuItem>
<MenuItem>
{({ focus }) => (
<Link to="/logout" className={focus ? 'active' : ''}>
Logout
</Link>
)}
<Link
to="/logout"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Logout
</Link>
</MenuItem>
</MenuItems>
</Menu>

View File

@@ -1,3 +1,5 @@
import { Tab, TabGroup, TabList } from '@headlessui/react';
export type ProfileTab = 'activity' | 'following' | 'liked';
const TABS: { id: ProfileTab; label: string }[] = [
@@ -11,28 +13,23 @@ interface ProfileTabsProps {
onChange: (tab: ProfileTab) => void;
}
const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => (
<div className="flex gap-6 border-b border-base-300" role="tablist">
{TABS.map((tab) => {
const active = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(tab.id)}
className={`py-3 text-sm font-semibold border-b-2 -mb-px transition-colors ${
active
? 'text-primary border-primary'
: 'text-base-content/60 border-transparent hover:text-base-content'
}`}
>
{tab.label}
</button>
);
})}
</div>
);
const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => {
const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
return (
<TabGroup selectedIndex={selectedIndex} onChange={(index) => onChange(TABS[index].id)}>
<TabList className="flex gap-6 border-b border-base-300">
{TABS.map((tab) => (
<Tab
key={tab.id}
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
>
{tab.label}
</Tab>
))}
</TabList>
</TabGroup>
);
};
export default ProfileTabs;