@aiquants/menu-bar 1.18.3 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,13 @@ A React menu bar component with authentication support and theming for modern we
4
4
 
5
5
  ## Key Features
6
6
 
7
- - **Menu Navigation**: Collapsible menu with smooth animations and hierarchical structure
7
+ - **Menu Navigation**: A drawer that slides in from the right, with top-level links and collapsible groups
8
+ - **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
8
9
  - **Dark/Light Theme**: Comprehensive theming support with scoped CSS
9
10
  - **Session Management**: Built-in session information display with user profiles
10
- - **Responsive Design**: Mobile-friendly with overlay support
11
+ - **Pinning**: Keep the drawer open while navigating, with the pinned width exposed as a CSS variable and an event
12
+ - **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
13
+ - **Configurable Switch**: Position, size, layering and caption of the open/close switch set through CSS custom properties
11
14
  - **TypeScript Support**: Full TypeScript definitions included
12
15
  - **React Router Integration**: Built for React Router with generic link component support
13
16
  - **CSS Scoped**: All styles are scoped to prevent conflicts with host applications
@@ -20,18 +23,26 @@ npm install @aiquants/menu-bar
20
23
 
21
24
  ## Exports
22
25
 
23
- This package exports the following components and types:
26
+ This package exports the following components, functions and types:
24
27
 
25
28
  ```tsx
26
29
  // Components
27
30
  export { MenuBar } from '@aiquants/menu-bar';
28
31
  export { SessionInfo, DefaultLink } from '@aiquants/menu-bar';
29
32
 
33
+ // Pin state (event and hook)
34
+ export { useMenuPinState, subscribeMenuPinChange, dispatchMenuPinChange, getInitialPinState, MENU_PIN_CHANGE_EVENT } from '@aiquants/menu-bar';
35
+
30
36
  // Types
31
- export type { IMenu, ISubmenu, MenuNavigationPayload } from '@aiquants/menu-bar';
37
+ export type { IMenuBarProps } from '@aiquants/menu-bar';
38
+ export type { IMenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from '@aiquants/menu-bar';
32
39
  export type { Theme } from '@aiquants/menu-bar';
33
40
  export type { SessionInfoProps, LinkProps } from '@aiquants/menu-bar';
34
41
  export type { UserProfile } from '@aiquants/menu-bar';
42
+ export type { MenuPinChangeDetail, MenuPinCustomEvent, SubscribeMenuPinOptions, UseMenuPinStateOptions } from '@aiquants/menu-bar';
43
+
44
+ // Collision-free aliases of the types above
45
+ export type { IMenuBarMenu, IMenuBarSubmenu, MenuBarTheme, MenuBarUserProfile, MenuBarLinkProps, MenuBarSessionInfoProps } from '@aiquants/menu-bar';
35
46
 
36
47
  // CSS — non-Tailwind hosts load the self-contained standalone build once (Tailwind hosts: see "Styling (CSS)")
37
48
  import '@aiquants/menu-bar/styles/menu-bar.standalone.css';
@@ -87,6 +98,8 @@ function App() {
87
98
  }
88
99
  ```
89
100
 
101
+ `MenuBar` renders React Router links, so place it inside a router provider.
102
+
90
103
  ## Props
91
104
 
92
105
  ### MenuBar Props
@@ -98,9 +111,12 @@ function App() {
98
111
  | `toggleTheme` | `() => void` | Yes | Function to toggle between light and dark themes |
99
112
  | `onClickSignout` | `() => void` | Yes | Callback function for sign out action |
100
113
  | `menuHeader` | `React.ReactNode` | No | Optional header content for the menu |
101
- | `isMenuhide` | `boolean` | No | Whether to hide the menu bar entirely |
114
+ | `isMenuHide` | `boolean` | No | Whether to hide the menu bar entirely (renders nothing) |
115
+ | `isMenuhide` | `boolean` | No | Legacy spelling of `isMenuHide`; read only when `isMenuHide` is not given |
102
116
  | `onMenuNavigate` | `(payload: MenuNavigationPayload) => void` | No | Callback function triggered when a menu or submenu item is selected |
103
- | `onPinChange` | `(pinned: boolean) => void` | No | Callback function triggered when the menu bar's pin state changes |
117
+ | `isPinned` | `boolean` | No | Controlled pin state. When omitted, the menu bar keeps the pin state itself and stores it in `localStorage` |
118
+ | `pinnedWidth` | `number` | No | Width in pixels reported while pinned (default `240`) |
119
+ | `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) |
104
120
  | `pinnedSubmenuIds` | `number[]` | No | Pinned submenu IDs (controlled) |
105
121
  | `initialPinnedSubmenuIds` | `number[]` | No | Initial pinned submenu IDs |
106
122
  | `onSubmenuPinChange` | `(pinnedIds: number[]) => void` | No | Callback function triggered when submenu pin states change |
@@ -114,6 +130,7 @@ interface IMenu {
114
130
  title: string;
115
131
  href?: string;
116
132
  submenu: ISubmenu[];
133
+ match?: MenuBarPathMatch; // ignored on a group without href
117
134
  }
118
135
  ```
119
136
 
@@ -126,9 +143,18 @@ interface ISubmenu {
126
143
  href: string;
127
144
  mark?: string;
128
145
  new?: boolean;
146
+ match?: MenuBarPathMatch;
129
147
  }
130
148
  ```
131
149
 
150
+ ### MenuBarPathMatch Type
151
+
152
+ ```tsx
153
+ type MenuBarPathMatch = 'exact' | 'prefix';
154
+ ```
155
+
156
+ How an entry's `href` is matched against the current path. See "Current Page Highlighting" below.
157
+
132
158
  ### MenuNavigationPayload Type
133
159
 
134
160
  ```tsx
@@ -167,17 +193,17 @@ interface LinkProps {
167
193
 
168
194
  The package ships **two CSS artifacts** — pick the one that matches your host.
169
195
 
170
- - **Tailwind v4 host** — import the components-only build into the `components` layer, and let your own Tailwind build generate the JSX utility classes by scanning the package source:
196
+ - **Tailwind v4 host** — import the components-only build into the `components` layer, and let your own Tailwind build generate the JSX utility classes by scanning the package's built JavaScript (the published package ships `dist`, not `src`; the class names stay intact in the bundle):
171
197
 
172
198
  ```css
173
199
  /* app tailwind.css */
174
200
  @import "@aiquants/menu-bar/styles/menu-bar.css" layer(components);
175
- @source "../node_modules/@aiquants/menu-bar/src/**/*.{ts,tsx}";
176
- /* monorepo: @source "../../../../packages/menu-bar/src/**/*.{ts,tsx}"; */
201
+ @source "../node_modules/@aiquants/menu-bar/dist/**/*.js";
202
+ /* the package as a workspace sibling: @source "../../../../packages/menu-bar/src/**/*.{ts,tsx}"; */
177
203
  ```
178
204
 
179
- `menu-bar.css` (Artifact A) carries only the hand-written `.aiquants-*` component classes — no `:root` theme variables, no Tailwind utilities, no preflight — so it drops into `layer(components)` safely.
180
- The one `:root` rule it emits is menu-bar's own runtime variable `--aiquants-menu-pinned-width`, which the component sets inline at runtime (a runtime input, not a theme override).
205
+ `menu-bar.css` (Artifact A) carries only the hand-written `.aiquants-*` component classes and the package's own `:root` variables — no Tailwind theme variables, no Tailwind utilities, no preflight — so it drops into `layer(components)` safely.
206
+ Its `:root` rule 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).
181
207
  Do **not** also `@import` the 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.
182
208
 
183
209
  - **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:
@@ -188,9 +214,47 @@ The package ships **two CSS artifacts** — pick the one that matches your host.
188
214
 
189
215
  Dark mode keys off the `.dark` class — add `.dark` to `<html>` to enable the dark styles.
190
216
 
217
+ 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.
218
+
219
+ ## Menu Switch Custom Properties
220
+
221
+ 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).
222
+
223
+ | Property | Default | Effect |
224
+ | --- | --- | --- |
225
+ | `--aqmb-switch-position` | `fixed` | `position` of the switch |
226
+ | `--aqmb-switch-top` | `6px` | `top` of the switch |
227
+ | `--aqmb-switch-right` | `8px` | `right` of the switch |
228
+ | `--aqmb-switch-left` | `auto` | `left` of the switch |
229
+ | `--aqmb-switch-bottom` | `auto` | `bottom` of the switch |
230
+ | `--aqmb-switch-z-index` | `2147483647` | `z-index` of the switch |
231
+ | `--aqmb-switch-size` | `27px` | Side S of the switch's square box; every length of the drawing derives from it |
232
+ | `--aqmb-switch-caption-display` | `block` | `display` of the caption; `none` hides it |
233
+
234
+ How the drawing follows S:
235
+
236
+ - 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.
237
+ - The caption's top edge is S/2 + 0.5rem below the top of the stack.
238
+ - 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.
239
+ - 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.
240
+ - The default `27px` gives 3px bars spaced 6px apart, a 32px × 4px cross, and a caption 21.5px below the top of the box.
241
+ - 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.
242
+
243
+ Example — fit the switch into a 32px header bar (4px + 24px line box + 4px):
244
+
245
+ ```css
246
+ :root {
247
+ --aqmb-switch-size: 24px;
248
+ --aqmb-switch-caption-display: none;
249
+ --aqmb-switch-top: 4px;
250
+ }
251
+ ```
252
+
253
+ 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.
254
+
191
255
  ## Theming
192
256
 
193
- The menu bar supports both light and dark themes. All styles are scoped with the `.aiquants-menu-bar` class to prevent conflicts with your application's styles.
257
+ The menu bar supports both light and dark themes. All styles are scoped with the `.aiquants-menu-bar` class to prevent conflicts with your application's styles. The root element carries the theme as a class: `.aiquants-menu-bar.light` or `.aiquants-menu-bar.dark`.
194
258
 
195
259
  ### Custom Styling
196
260
 
@@ -201,11 +265,11 @@ You can override the default styles by targeting the scoped classes:
201
265
  /* Custom menu bar styles */
202
266
  }
203
267
 
204
- .aiquants-menu-bar.theme-dark {
268
+ .aiquants-menu-bar.dark {
205
269
  /* Dark theme overrides */
206
270
  }
207
271
 
208
- .aiquants-menu-bar.theme-light {
272
+ .aiquants-menu-bar.light {
209
273
  /* Light theme overrides */
210
274
  }
211
275
  ```
@@ -247,6 +311,60 @@ function App() {
247
311
  }
248
312
  ```
249
313
 
314
+ ## Current Page Highlighting
315
+
316
+ 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`:
317
+
318
+ ```tsx
319
+ const menuList: IMenu[] = [
320
+ { id: 1, title: 'Home', href: '/', submenu: [] },
321
+ {
322
+ id: 2,
323
+ title: 'Admin',
324
+ submenu: [
325
+ // Also the current entry on /admin/console/search, /admin/console/users/42, ...
326
+ { subid: 1, title: 'Console', href: '/admin/console', match: 'prefix' },
327
+ { subid: 2, title: 'Audit log', href: '/admin/audit' },
328
+ ],
329
+ },
330
+ ];
331
+ ```
332
+
333
+ The rule:
334
+
335
+ - `'exact'` (the default) matches the path itself. `'prefix'` also matches every path below the `href` at a segment boundary: `/admin` matches `/admin/users`, not `/administration`. Any other value behaves as `'exact'`.
336
+ - The query, the hash and a trailing slash are ignored on both the `href` and the path.
337
+ - An `href` of `/` matches only the root, even with `'prefix'`. External URLs never match.
338
+ - When several entries match, the longest `href` wins, 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).
339
+
340
+ 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). 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.
341
+
342
+ ## Accessibility
343
+
344
+ - **Switch**: a native `<button>` named "Open menu" / "Close menu" (`aria-label`) with `aria-expanded`; the visible caption is `aria-hidden`. Keyboard focus shows a 2px ring (`:focus-visible`).
345
+ - **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 `inert` attribute.
346
+ - **Decorative backdrop**: the dimming layer behind the open drawer is an `aria-hidden` `div` that 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 (per `closeOnEscape`) or the switch.
347
+ - **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.
348
+ - **Current page**: the matched entry's link carries `aria-current="page"`.
349
+ - **Groups**: group summaries toggle with Enter or Space and show a focus outline on keyboard focus. Every button has an accessible name.
350
+ - **Reduced motion**: the drawer's slide, the backdrop's fade and the switch's transitions run only under `prefers-reduced-motion: no-preference`.
351
+ - **Forced colors**: the switch's bars are drawn in `ButtonText`, so they stay visible when forced colors replace backgrounds.
352
+
353
+ ## DOM Hooks
354
+
355
+ 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.
356
+
357
+ | Attribute | Element |
358
+ | --- | --- |
359
+ | `data-aqmb-menu-switch` | The open/close switch button |
360
+ | `data-aqmb-menu-content` | The drawer's root element |
361
+ | `data-aqmb-menu-overlay` | The backdrop |
362
+ | `data-aqmb-menu-list` | The scroll container of the menu list |
363
+ | `data-aqmb-pin-button` | The drawer's pin button |
364
+ | `data-aqmb-theme-button` | The theme toggle button |
365
+ | `data-aqmb-signout-button` | The sign-out button |
366
+ | `data-aqmb-submenu-pin-button` | The pin button of each group |
367
+
250
368
  ## Advanced Usage
251
369
 
252
370
  ### With Menu Header
@@ -280,7 +398,7 @@ import React from 'react';
280
398
  theme={theme}
281
399
  toggleTheme={toggleTheme}
282
400
  onClickSignout={handleSignout}
283
- isMenuhide={shouldHideMenu}
401
+ isMenuHide={shouldHideMenu}
284
402
  />
285
403
  ```
286
404
 
@@ -290,11 +408,11 @@ import React from 'react';
290
408
 
291
409
  The main menu bar component that includes:
292
410
 
293
- - Menu toggle button with hamburger icon
294
- - Company logo and navigation menu
295
- - Theme toggle functionality
296
- - Responsive overlay for mobile devices
297
- - Sign out functionality
411
+ - Open/close switch with four bars that turn into a cross
412
+ - Header slot (`menuHeader`) and navigation menu
413
+ - Pin, theme toggle and sign-out buttons
414
+ - Backdrop that closes the drawer when pressed
415
+ - Focus management and current page highlighting
298
416
 
299
417
  ### SessionInfo
300
418
 
@@ -322,18 +440,21 @@ import { SessionInfo } from '@aiquants/menu-bar';
322
440
 
323
441
  - **Hierarchical Menu Structure**: Support for main menu items with submenus
324
442
  - **Dynamic Navigation**: Menu items can have direct links or submenu collections
443
+ - **Current Page**: Exact or prefix matching per entry, with `aria-current="page"`
325
444
  - **Visual Indicators**: Support for special marks (lock icon, etc.) and "new" badges
326
445
  - **External Links**: Option to open links in new tabs
327
446
 
328
447
  ## CSS Classes
329
448
 
330
- All CSS classes are scoped with `.aiquants-` prefix:
449
+ All hand-written CSS classes use the `.aiquants-` prefix, for example:
331
450
 
332
- - `.aiquants-menu-bar` - Main container
333
- - `.aiquants-menu-switch` - Menu toggle button
334
- - `.aiquants-menu-content` - Menu content area
335
- - `.aiquants-session-info` - Session information
336
- - `.aiquants-overlay-shadow` - Mobile overlay
451
+ - `.aiquants-menu-bar` - Main container (with the theme class `light` or `dark`)
452
+ - `.aiquants-menu-switch` - Open/close switch (`.aiquants-menu-switch-bars`, `.aiquants-menu-switch-bar`, `.aiquants-menu-switch-caption`)
453
+ - `.aiquants-menu-content` - Drawer
454
+ - `.aiquants-menu-list-item` - Menu item and group summary
455
+ - `.aiquants-session-card` - Session information card
456
+
457
+ These classes are a styling contract, not DOM hooks — use the `data-aqmb-*` attributes above to select elements.
337
458
 
338
459
  ## Browser Support
339
460
 
@@ -346,18 +467,26 @@ All CSS classes are scoped with `.aiquants-` prefix:
346
467
 
347
468
  ```bash
348
469
  # Install dependencies
349
- npm install
470
+ pnpm install
350
471
 
351
472
  # Build the package
352
- npm run build
473
+ pnpm run build
353
474
 
354
475
  # Run type checking
355
- npm run typecheck
476
+ pnpm run typecheck
477
+
478
+ # Run the unit tests (Vitest, jsdom)
479
+ pnpm run test
480
+
481
+ # Run the browser tests (Playwright, Chromium) against a local harness page
482
+ pnpm run test:e2e
356
483
 
357
484
  # Clean build files
358
- npm run clean
485
+ pnpm run clean
359
486
  ```
360
487
 
488
+ The 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.
489
+
361
490
  ## License
362
491
 
363
492
  MIT License - see [LICENSE](LICENSE) file for details.
@@ -12,6 +12,7 @@ interface IMenuContentProps {
12
12
  onTogglePin(): void;
13
13
  pinnedSubmenuIds: number[];
14
14
  onToggleSubmenuPin(id: number): void;
15
+ ref: React.Ref<HTMLDivElement>;
15
16
  }
16
17
  export declare const MenuContent: (props: IMenuContentProps) => React.JSX.Element;
17
18
  export {};
@@ -1,15 +1,18 @@
1
+ export type MenuBarPathMatch = "exact" | "prefix";
1
2
  export interface ISubmenu {
2
3
  subid: number;
3
4
  title?: string;
4
5
  href: string;
5
6
  mark?: string;
6
7
  new?: boolean;
8
+ match?: MenuBarPathMatch;
7
9
  }
8
10
  export interface IMenu {
9
11
  id: number;
10
12
  title: string;
11
13
  href?: string;
12
14
  submenu: ISubmenu[];
15
+ match?: MenuBarPathMatch;
13
16
  }
14
17
  export type MenuNavigationPayload = {
15
18
  menu: Pick<IMenu, "id" | "title" | "href">;
@@ -1,6 +1,6 @@
1
1
  interface IOverlayProps {
2
2
  isOverlay: boolean;
3
- onOverlayClick?(): void;
3
+ onOverlayClick(): void;
4
4
  }
5
5
  export declare const OverlayShadow: (props: IOverlayProps) => React.JSX.Element;
6
6
  export {};
@@ -1,13 +1,8 @@
1
- import { Theme } from "../types";
2
- export interface IMenuSwitchProps {
3
- open?: boolean;
4
- isOpen?: boolean;
5
- onMenuOpen?: () => void;
6
- onMenuClose?: () => void;
7
- onToggle?: (nextOpen: boolean) => void;
8
- theme?: Theme;
9
- pinned?: boolean;
10
- isPinned?: boolean;
11
- className?: string;
12
- }
13
- export declare const MenuSwitch: (props: IMenuSwitchProps) => React.JSX.Element;
1
+ type MenuSwitchProps = {
2
+ isMenuOpen: boolean;
3
+ onMenuOpen(): void;
4
+ onMenuClose(): void;
5
+ ref: React.Ref<HTMLButtonElement>;
6
+ };
7
+ export declare const MenuSwitch: ({ isMenuOpen, onMenuOpen, onMenuClose, ref }: MenuSwitchProps) => React.JSX.Element;
8
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { LinkProps as MenuBarLinkProps, LinkProps, SessionInfoProps as MenuBarSessionInfoProps, SessionInfoProps } from "./components/SessionInfo";
2
2
  export { DefaultLink, SessionInfo } from "./components/SessionInfo";
3
- export type { IMenu as IMenuBarMenu, IMenu, ISubmenu as IMenuBarSubmenu, ISubmenu, MenuNavigationPayload } from "./contents/MenuList";
3
+ export type { IMenu as IMenuBarMenu, IMenu, ISubmenu as IMenuBarSubmenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from "./contents/MenuList";
4
4
  export type { SubscribeMenuPinOptions } from "./events/pinEvent";
5
5
  export { dispatchMenuPinChange, getInitialPinState, MENU_PIN_CHANGE_EVENT, subscribeMenuPinChange, } from "./events/pinEvent";
6
6
  export type { UseMenuPinStateOptions } from "./hooks/useMenuPinState";