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 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'; 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.`; 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 }); const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument(); await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton); await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument(); // MenuItems is anchor-positioned, which portals it outside canvasElement.
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument(); await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
await expect(canvas.getByRole('menuitem', { name: /logout/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) => ( const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
<Menu as="div" className="relative"> <Menu>
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton> <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> <MenuItem>
{({ focus }) => ( <Link
<Link to={`/users/${userAccountId}`} className={focus ? 'active' : ''}> to={`/users/${userAccountId}`}
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
My profile My profile
</Link> </Link>
)}
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
{({ focus }) => ( <Link
<Link to="/dashboard" className={focus ? 'active' : ''}> to="/dashboard"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Dashboard Dashboard
</Link> </Link>
)}
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
{({ focus }) => ( <Link
<Link to="/logout" className={focus ? 'active' : ''}> to="/logout"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Logout Logout
</Link> </Link>
)}
</MenuItem> </MenuItem>
</MenuItems> </MenuItems>
</Menu> </Menu>

View File

@@ -1,3 +1,5 @@
import { Tab, TabGroup, TabList } from '@headlessui/react';
export type ProfileTab = 'activity' | 'following' | 'liked'; export type ProfileTab = 'activity' | 'following' | 'liked';
const TABS: { id: ProfileTab; label: string }[] = [ const TABS: { id: ProfileTab; label: string }[] = [
@@ -11,28 +13,23 @@ interface ProfileTabsProps {
onChange: (tab: ProfileTab) => void; onChange: (tab: ProfileTab) => void;
} }
const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => ( const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => {
<div className="flex gap-6 border-b border-base-300" role="tablist"> const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
{TABS.map((tab) => {
const active = tab.id === activeTab;
return ( return (
<button <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} key={tab.id}
type="button" 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"
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} {tab.label}
</button> </Tab>
); ))}
})} </TabList>
</div> </TabGroup>
); );
};
export default ProfileTabs; export default ProfileTabs;