@aiquants/menu-bar
v1.19.1
Published
A React menu bar component with authentication support and theming
Downloads
231
Readme
@aiquants/menu-bar
A React menu bar component with authentication support and theming for modern web applications.
Key Features
- Menu Navigation: A drawer that slides in from the right, with top-level links and collapsible groups
- Current Page Highlighting: The entry the current route belongs to carries
aria-current="page"and its group opens, with exact or prefix matching per entry - Dark/Light Theme: Comprehensive theming support with scoped CSS
- Session Management: Built-in session information display with user profiles
- Pinning: Keep the drawer open while navigating, with the pinned width exposed as a CSS variable and an event
- Accessible: Named switch with
aria-expanded, an inert closed drawer, focus that returns to the switch, visible focus rings, reduced-motion and forced-colors support - Configurable Switch: Position, size, layering and caption of the open/close switch set through CSS custom properties
- TypeScript Support: Full TypeScript definitions included
- React Router Integration: Built for React Router with generic link component support
- Scoped Component CSS: The hand-written rules are scoped to
.aiquants-*classes and package-prefixed variables; the standalone build also carries Tailwind utilities and theme variables, which are global (see "Styling (CSS)")
Installation
npm install @aiquants/menu-barPeer dependencies: react and react-dom ^19.2.7, and react-router-dom ^7.7.1. The package's engines field asks for Node.js 18 or later.
Exports
This package exports the following components, functions and types:
// Components
export { MenuBar } from '@aiquants/menu-bar';
export { SessionInfo, DefaultLink } from '@aiquants/menu-bar';
// Pin state (event and hook)
export { useMenuPinState, subscribeMenuPinChange, dispatchMenuPinChange, getInitialPinState, MENU_PIN_CHANGE_EVENT } from '@aiquants/menu-bar';
// Types
export type { IMenuBarProps } from '@aiquants/menu-bar';
export type { IMenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from '@aiquants/menu-bar';
export type { Theme } from '@aiquants/menu-bar';
export type { SessionInfoProps, LinkProps } from '@aiquants/menu-bar';
export type { UserProfile } from '@aiquants/menu-bar';
export type { MenuPinChangeDetail, MenuPinCustomEvent, SubscribeMenuPinOptions, UseMenuPinStateOptions } from '@aiquants/menu-bar';
// Collision-free aliases of the types above
export type { IMenuBarMenu, IMenuBarSubmenu, MenuBarTheme, MenuBarUserProfile, MenuBarLinkProps, MenuBarSessionInfoProps } from '@aiquants/menu-bar';
// CSS — non-Tailwind hosts load the self-contained standalone build once (Tailwind hosts: see "Styling (CSS)")
import '@aiquants/menu-bar/styles/menu-bar.standalone.css';Basic Usage
import React, { useState } from 'react';
import { MenuBar, type Theme } from '@aiquants/menu-bar';
import '@aiquants/menu-bar/styles/menu-bar.standalone.css';
function App() {
const [theme, setTheme] = useState<Theme>('light');
const menuList = [
{
id: 1,
title: 'Dashboard',
href: '/dashboard',
submenu: []
},
{
id: 2,
title: 'Data',
submenu: [
{ subid: 1, title: 'Reports', href: '/data/reports' },
{ subid: 2, title: 'Analytics', href: '/data/analytics' }
]
}
];
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
const handleSignout = () => {
console.log('Signing out...');
};
return (
<div className="app">
<MenuBar
menuList={menuList}
theme={theme}
toggleTheme={toggleTheme}
onClickSignout={handleSignout}
menuHeader={<div>My App</div>}
/>
{/* Your app content */}
</div>
);
}MenuBar renders React Router links, so place it inside a router provider.
Props
MenuBar Props
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| menuList | IMenu[] | Yes | Array of menu items to display |
| theme | 'light' \| 'dark' | Yes | Theme mode for the menu bar |
| toggleTheme | () => void | Yes | Function to toggle between light and dark themes |
| onClickSignout | () => void | Yes | Callback function for sign out action |
| menuHeader | React.ReactNode | No | Optional header content for the menu |
| isMenuHide | boolean | No | Whether to hide the menu bar entirely. Renders nothing while true, but the pin state is still reported: while pinned, --aiquants-menu-pinned-width and the pin event keep the pinned width (see "Pin State") |
| isMenuhide | boolean | No | Legacy spelling of isMenuHide; read only when isMenuHide is not given |
| onMenuNavigate | (payload: MenuNavigationPayload) => void | No | Callback function triggered when a menu or submenu item is selected |
| isPinned | boolean | No | Controlled pin state. When omitted, the menu bar keeps the pin state itself and stores it in localStorage |
| pinnedWidth | number | No | Width in pixels reported while pinned (default 240) |
| onPinChange | (pinned: boolean, widthPx: number) => void | No | Called when the pin button toggles the pin state, and with (false, 0) whenever the switch, the backdrop or Escape closes the drawer (not when selecting an item closes it) |
| pinnedSubmenuIds | number[] | No | Pinned submenu IDs (controlled) |
| initialPinnedSubmenuIds | number[] | No | Initial pinned submenu IDs |
| onSubmenuPinChange | (pinnedIds: number[]) => void | No | Callback function triggered when submenu pin states change |
| closeOnEscape | boolean \| "auto" | No | Controls whether the Escape key closes the menu. "auto" (default) closes only when not pinned; true always closes; false never closes |
IMenu Type
interface IMenu {
id: number;
title: string;
href?: string;
submenu: ISubmenu[];
match?: MenuBarPathMatch; // ignored on a group without href
}ISubmenu Type
interface ISubmenu {
subid: number;
title?: string;
href: string;
mark?: string;
new?: boolean;
match?: MenuBarPathMatch;
}MenuBarPathMatch Type
type MenuBarPathMatch = 'exact' | 'prefix';How an entry's href is matched against the current path. See "Current Page Highlighting" below.
MenuNavigationPayload Type
type MenuNavigationPayload = {
menu: Pick<IMenu, 'id' | 'title' | 'href'>;
submenu?: Pick<ISubmenu, 'subid' | 'title' | 'href' | 'mark' | 'new'>;
};UserProfile Type
interface UserProfile {
displayName: string;
photo: string;
id?: string;
}SessionInfoProps Type
interface SessionInfoProps {
user: UserProfile | null;
LinkComponent?: React.ComponentType<LinkProps>;
}
interface LinkProps {
to: string;
className?: string;
children: React.ReactNode;
}Styling (CSS)
The package ships two CSS artifacts — pick the one that matches your host.
Tailwind v4 host — import the components-only build into the
componentslayer, and let your own Tailwind build generate the JSX utility classes by scanning the package's built JavaScript (the published package shipsdist, notsrc; the class names stay intact in the bundle):/* app tailwind.css */ @import "@aiquants/menu-bar/styles/menu-bar.css" layer(components); @source "../node_modules/@aiquants/menu-bar/dist/**/*.js"; /* consuming the package's sources from a workspace: point @source at its src/**/*.{ts,tsx} instead */ @custom-variant dark (&:where(.dark, .dark *));The
@custom-variantline makes the menu bar'sdark:utilities follow thethemeprop: the menu bar's root carries.darkwhilethemeis'dark'. With Tailwind's defaultdarkvariant (prefers-color-scheme), they follow the OS setting instead. The variant applies to your whole build, so your owndark:utilities then key off a.darkancestor as well.menu-bar.css(Artifact A) carries only the hand-written.aiquants-*component classes and the package's own:rootvariables — no Tailwind theme variables, no Tailwind utilities, no preflight — so it drops intolayer(components)safely. Its:rootrule declares menu-bar's runtime variable--aiquants-menu-pinned-width(which the component sets inline at runtime) and the defaults of the menu switch custom properties (--aqmb-switch-*, see below). Do not also@importthe standalone build in a Tailwind host: it re-emits the same utilities your build produces, and two concatenated builds cannot reproduce the single-build canonical order (base → variants), so base/variant cascade winners flip.Non-Tailwind host — load the single self-contained standalone build (via
<link>or a plain@import); it bundles the component classes plus every JSX utility:@import "@aiquants/menu-bar/styles/menu-bar.standalone.css";The utilities in this build are global selectors (
.flex,.fixed,.hidden, ...), and it declares the Tailwind theme variables it uses on:root, :host, so a host element with one of those class names picks up the rule.Dark mode follows the
themeprop: the menu bar's root carries.darkwhilethemeis'dark', and this build'sdark:utilities apply to an element with the.darkclass or inside one, so<html>needs no class. ASessionInforendered outsideMenuBartakes its dark styles from a.darkancestor of its own. If you also put.darkon<html>, keep it in step withtheme: withtheme="light"it turns on thedark:utilities inside the light menu bar while the light colours of the hand-written rules stay, mixing the two palettes.
In both setups the component classes sit in the components layer and the utilities in the later utilities layer, so a utility wins over a component rule for the same property.
Menu Switch Custom Properties
The open/close switch is a square box with four bars that turn into a cross while the drawer is open, over a "menu" / "close" caption. Its whole drawing lives in the package stylesheet, and it carries no utility classes, so every custom property below takes effect. The defaults are declared on :root; override them on :root (or any ancestor of the menu bar).
| Property | Default | Effect |
| --- | --- | --- |
| --aqmb-switch-position | fixed | position of the switch |
| --aqmb-switch-top | 6px | top of the switch |
| --aqmb-switch-right | 8px | right of the switch |
| --aqmb-switch-left | auto | left of the switch |
| --aqmb-switch-bottom | auto | bottom of the switch |
| --aqmb-switch-z-index | calc(infinity) | z-index of the switch. The default computes to the largest integer the browser supports (2147483647 in Chromium), which keeps the switch above the drawer (z-index 2147483642) and the backdrop (2147483640) |
| --aqmb-switch-size | 27px | Side S of the switch's square box; every length of the drawing derives from it |
| --aqmb-switch-caption-display | block | display of the caption; none hides it |
The z-index default is calc(infinity) rather than a number because Lightning CSS, which Tailwind v4 builds pass stylesheets through, writes the numbers of custom-property values and var() fallbacks with 6 significant digits: 2147483647 would reach the browser as 2147480000, below the drawer. An override in a stylesheet that also goes through Lightning CSS is rounded the same way when it has 7 or more significant digits. A small value such as 50 is kept as written, and stacks the switch below the drawer and the backdrop.
How the drawing follows S:
- The bars are S/9 thick, at 0, 2S/9, 4S/9 and 6S/9 of a stack 7S/9 high. While open, the outer bars become 32S/27 wide and 4S/27 thick and cross at ±45°; the inner bars fade out.
- The caption's top edge is S/2 + 0.5rem below the top of the stack.
- With the caption shown (the default) and S below 48px (at a 16px root font size), the column of bars and caption is taller than the box: it starts at the box's top and the caption hangs below the box.
- With
--aqmb-switch-caption-display: none, the bars are centred in the box and nothing is drawn above or below it. The cross can still extend slightly past the box's right edge, as it does in the default drawing. - The default
27pxgives 3px bars spaced 6px apart, a 32px × 4px cross, and a caption 21.5px below the top of the box. - The switch draws in black, and in white on the dark theme and while the drawer is open. Its keyboard focus ring is a 2px outline in the same colour at a 2px offset, so it occupies 4px around the box.
Example — fit the switch into a 32px header bar (4px + 24px line box + 4px):
:root {
--aqmb-switch-size: 24px;
--aqmb-switch-caption-display: none;
--aqmb-switch-top: 4px;
}The box sits on the bar's 24px line box, and the focus ring (0 to 32px from the top) stays inside the bar. Adjust --aqmb-switch-right to align it horizontally.
Theming
The menu bar supports both light and dark themes. The hand-written styles are scoped with the .aiquants-menu-bar class; the utility classes and Tailwind theme variables of the standalone build are global (see "Styling (CSS)"). The root element carries the theme as a class: .aiquants-menu-bar.light or .aiquants-menu-bar.dark.
Custom Styling
You can override the default styles by targeting the scoped classes:
.aiquants-menu-bar {
/* Custom menu bar styles */
}
.aiquants-menu-bar.dark {
/* Dark theme overrides */
}
.aiquants-menu-bar.light {
/* Light theme overrides */
}Usage with React Router
import React, { useState } from 'react';
import { MenuBar, type Theme } from '@aiquants/menu-bar';
function App() {
const [theme, setTheme] = useState<Theme>('light');
const menuList = [
{
id: 1,
title: 'Home',
href: '/',
submenu: []
},
{
id: 2,
title: 'Products',
submenu: [
{ subid: 1, title: 'All Products', href: '/products' },
{ subid: 2, title: 'Categories', href: '/products/categories' }
]
}
];
return (
<MenuBar
menuList={menuList}
theme={theme}
toggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
onClickSignout={() => console.log('Signout')}
/>
);
}Current Page Highlighting
The menu finds the one entry the current route belongs to, marks its link with aria-current="page", and opens its group. Each top-level item with an href and each submenu item can choose how its href is matched with match:
const menuList: IMenu[] = [
{ id: 1, title: 'Home', href: '/', submenu: [] },
{
id: 2,
title: 'Admin',
submenu: [
// Also the current entry on /admin/console/search, /admin/console/users/42, ...
{ subid: 1, title: 'Console', href: '/admin/console', match: 'prefix' },
{ subid: 2, title: 'Audit log', href: '/admin/audit' },
],
},
];The rule:
'exact'(the default) matches the path itself.'prefix'also matches every path below thehrefat a segment boundary:/adminmatches/admin/users, not/administration. Any other value behaves as'exact'.- The query, the hash and a trailing slash are ignored on both the
hrefand the path. - An
hrefof/matches only the root, even with'prefix'. External URLs never match. - When several entries match, the longest
hrefwins, across groups too; an exact match therefore always beats a prefix match. On a tie, the first entry in menu order wins (a top-level item before its own submenu items).
The group of the matched entry opens once per route change, and only one group is open at a time (pinned groups always stay open). When the matched entry is a top-level link, no group opens, and the group that was open closes. A route with no matching entry leaves the open group as it is, and reloading the menu on the same route does not reopen a group the user closed.
Pin State
The pin button keeps the drawer open, without the backdrop, while the user navigates. When it mounts and whenever the pin state, pinnedWidth or the open state changes, the menu bar reports the state in two ways, and it resets both to "not pinned" when it unmounts:
- CSS variable:
--aiquants-menu-pinned-widthon<html>holds the pinned width (pinnedWidth, default240) in pixels while pinned, and0pxotherwise. - Event: a
CustomEventnamed byMENU_PIN_CHANGE_EVENT("aiquants-menu-pin-change") onwindow. Itsdetailis aMenuPinChangeDetail,{ pinned, width, active? }:widthmatches the CSS variable, andactivetells whether the drawer is open or pinned.
These exports read the state:
| Export | Behaviour |
| --- | --- |
| useMenuPinState(options?) | Returns the current MenuPinChangeDetail and re-renders when it changes. Options: initialPinned (default false) and defaultPinnedWidth (default 240). During server rendering it returns { pinned: initialPinned, width: initialPinned ? defaultPinnedWidth : 0 }, without active |
| subscribeMenuPinChange(listener, options?) | Calls listener with the detail of every event and returns a function that unsubscribes. Options: immediate (default true: also calls listener once right away with getInitialPinState(defaultPinnedWidth)) and defaultPinnedWidth (default 240) |
| getInitialPinState(defaultPinnedWidth = 240) | Reads the CSS variable: pinned when it is a positive number of pixels, which is then the width. When the variable is empty or not a number, it reads the aiquants-menu-pinned localStorage key instead ("true": pinned at defaultPinnedWidth); the package stylesheet declares the variable as 0px, so this happens only while neither the stylesheet nor a menu bar has set it. active is that of the last dispatchMenuPinChange call (false before the first) |
| dispatchMenuPinChange(pinned, width, active = pinned) | Dispatches the event on window. The menu bar calls it itself |
import type { ReactNode } from 'react';
import { useMenuPinState } from '@aiquants/menu-bar';
function Page({ children }: { children: ReactNode }) {
const { width } = useMenuPinState();
// The pinned drawer covers the right edge of the viewport
return <main style={{ paddingRight: width }}>{children}</main>;
}Accessibility
- Switch: a native
<button>named "Open menu" / "Close menu" (aria-label) witharia-expanded; the visible caption isaria-hidden. Keyboard focus shows a 2px ring (:focus-visible). - Closed drawer is inert: while closed, nothing in the drawer takes focus (by Tab or by script) or appears in the accessibility tree, so the switch is the menu bar's only Tab stop. A pinned drawer stays open and interactive. This relies on the HTML
inertattribute. - Decorative backdrop: the dimming layer behind the open drawer is an
aria-hiddendivthat never takes focus. A pointer press on it closes the drawer without moving the focus; keyboard and assistive-technology users close the drawer with Escape (percloseOnEscape) or the switch. - Focus return: when the drawer closes — by the switch, the backdrop, Escape or selecting an item while not pinned — with the focus inside it, the focus moves to the switch. A focus outside the drawer stays where it is.
- Current page: the matched entry's link carries
aria-current="page". - Groups: group summaries toggle with Enter or Space and show a focus outline on keyboard focus. Every button has an accessible name.
- Reduced motion: the drawer's slide, the backdrop's fade and the switch's transitions run only under
prefers-reduced-motion: no-preference. - Forced colors: the switch's bars are drawn in
ButtonText, so they stay visible when forced colors replace backgrounds. - Built-in labels: the names and texts are fixed, and no prop localises them. The switch is named in English ("Open menu" / "Close menu", caption "menu" / "close"); the drawer's pin and theme buttons, the sign-out button and the group pin buttons are named in Japanese (for example "ピン留め", "ダークモードに切り替え", "サインアウト"), and
SessionInfoshows Japanese texts such as "プロフィール" and "ログインしてください".
DOM Hooks
Select the menu bar's elements in scripts and tests by these attributes (or by role and accessible name). Class names — .aiquants-* and utilities — are styling and may change.
| Attribute | Element |
| --- | --- |
| data-aqmb-menu-switch | The open/close switch button |
| data-aqmb-menu-content | The drawer's root element |
| data-aqmb-menu-overlay | The backdrop |
| data-aqmb-menu-list | The scroll container of the menu list |
| data-aqmb-pin-button | The drawer's pin button |
| data-aqmb-theme-button | The theme toggle button |
| data-aqmb-signout-button | The sign-out button |
| data-aqmb-submenu-pin-button | The pin button of each group |
| data-aqmb-session-card | The SessionInfo card, with or without a user |
Advanced Usage
With Menu Header
import React from 'react';
const menuHeader = (
<div className="flex items-center gap-2 p-4">
<img src="/logo.png" alt="Logo" className="w-8 h-8" />
<span className="font-bold">My Application</span>
</div>
);
<MenuBar
menuList={menuList}
menuHeader={menuHeader}
theme={theme}
toggleTheme={toggleTheme}
onClickSignout={handleSignout}
/>Hiding the Menu
import React from 'react';
<MenuBar
menuList={menuList}
theme={theme}
toggleTheme={toggleTheme}
onClickSignout={handleSignout}
isMenuHide={shouldHideMenu}
/>Components
MenuBar
The main menu bar component that includes:
- Open/close switch with four bars that turn into a cross
- Header slot (
menuHeader) and navigation menu - Pin, theme toggle and sign-out buttons
- Backdrop that closes the drawer when pressed
- Focus management and current page highlighting
SessionInfo
A standalone session information component that displays:
- The user's avatar (a default icon when
photois empty), display name and a "Google Account" label - Two links rendered through the customizable
LinkComponent(default:DefaultLink, a plain<a>): a profile link to/and a link tohttps://myaccount.google.com/ - A "please sign in" card ("ログインしてください") when
userisnull
The card carries data-aqmb-session-card in both states. Sign-out is not part of SessionInfo: it is the drawer's sign-out button, which calls onClickSignout.
import { SessionInfo } from '@aiquants/menu-bar';
<SessionInfo
user={{
displayName: "John Doe",
photo: "https://example.com/avatar.jpg"
}}
LinkComponent={({ to, children, ...props }) => (
<a href={to} {...props}>{children}</a>
)}
/>Menu Features
- Hierarchical Menu Structure: Support for main menu items with submenus
- Dynamic Navigation: Menu items can have direct links or submenu collections
- Current Page: Exact or prefix matching per entry, with
aria-current="page" - Visual Indicators: An icon per submenu
mark(report,slip,table,chart,download,lock,href); any other value, or none, renders the text only - External Links:
new: trueopens a submenu item in a new tab (target="_blank",rel="noopener noreferrer"); it draws no badge
CSS Classes
Hand-written component classes use the .aiquants- prefix (the modifiers are the theme class light / dark on the root and pinned on a pinned group's pin button), for example:
.aiquants-menu-bar- Main container (with the theme classlightordark).aiquants-menu-switch- Open/close switch (.aiquants-menu-switch-bars,.aiquants-menu-switch-bar,.aiquants-menu-switch-caption).aiquants-menu-content- Drawer.aiquants-menu-list-item- Menu item and group summary.aiquants-session-card- Session information card
The menu list's scroll container also carries hidden-scrollbar, a class the package does not define, so a host rule of that name applies to it.
These classes are a styling contract, not DOM hooks — use the data-aqmb-* attributes above to select elements.
Browser Support
- Chrome/Edge 111+
- Firefox 128+
- Safari 16.4+
These are the versions Tailwind CSS v4, which builds both stylesheets, targets. The stylesheets also sit in cascade layers, which an older browser drops whole, and the closed drawer and the backdrop rely on the HTML inert attribute.
Development
# Install dependencies
pnpm install
# Build the package
pnpm run build
# Run type checking
pnpm run typecheck
# Run the unit tests (Vitest, jsdom)
pnpm run test
# Run the browser tests (Playwright, Chromium) against a local harness page
pnpm run test:e2e
# Clean build files
pnpm run cleanThe browser tests start a Vite server for the harness on port 25178; set MENU_BAR_E2E_PORT to use another port, and MENU_BAR_E2E_REUSE_SERVER=1 to reuse a server that is already running. Install Chromium once with pnpm exec playwright install chromium if it is missing.
License
MIT License - see LICENSE file for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Author
fehde-k
