@aiquants/menu-bar 1.18.3 → 1.19.1
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 +217 -45
- package/dist/contents/MenuContent.d.ts +1 -0
- package/dist/contents/MenuList.d.ts +3 -0
- package/dist/contents/OverlayShadow.d.ts +1 -1
- package/dist/elements/MenuSwitch.d.ts +8 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +498 -488
- package/dist/index.umd.js +2 -2
- package/dist/styles/menu-bar.css +1 -1
- package/dist/styles/menu-bar.standalone.css +1 -1
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -4,13 +4,16 @@ A React menu bar component with authentication support and theming for modern we
|
|
|
4
4
|
|
|
5
5
|
## Key Features
|
|
6
6
|
|
|
7
|
-
- **Menu Navigation**:
|
|
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
|
-
- **
|
|
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
|
-
- **CSS
|
|
16
|
+
- **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)")
|
|
14
17
|
|
|
15
18
|
## Installation
|
|
16
19
|
|
|
@@ -18,20 +21,30 @@ A React menu bar component with authentication support and theming for modern we
|
|
|
18
21
|
npm install @aiquants/menu-bar
|
|
19
22
|
```
|
|
20
23
|
|
|
24
|
+
Peer 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.
|
|
25
|
+
|
|
21
26
|
## Exports
|
|
22
27
|
|
|
23
|
-
This package exports the following components and types:
|
|
28
|
+
This package exports the following components, functions and types:
|
|
24
29
|
|
|
25
30
|
```tsx
|
|
26
31
|
// Components
|
|
27
32
|
export { MenuBar } from '@aiquants/menu-bar';
|
|
28
33
|
export { SessionInfo, DefaultLink } from '@aiquants/menu-bar';
|
|
29
34
|
|
|
35
|
+
// Pin state (event and hook)
|
|
36
|
+
export { useMenuPinState, subscribeMenuPinChange, dispatchMenuPinChange, getInitialPinState, MENU_PIN_CHANGE_EVENT } from '@aiquants/menu-bar';
|
|
37
|
+
|
|
30
38
|
// Types
|
|
31
|
-
export type {
|
|
39
|
+
export type { IMenuBarProps } from '@aiquants/menu-bar';
|
|
40
|
+
export type { IMenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from '@aiquants/menu-bar';
|
|
32
41
|
export type { Theme } from '@aiquants/menu-bar';
|
|
33
42
|
export type { SessionInfoProps, LinkProps } from '@aiquants/menu-bar';
|
|
34
43
|
export type { UserProfile } from '@aiquants/menu-bar';
|
|
44
|
+
export type { MenuPinChangeDetail, MenuPinCustomEvent, SubscribeMenuPinOptions, UseMenuPinStateOptions } from '@aiquants/menu-bar';
|
|
45
|
+
|
|
46
|
+
// Collision-free aliases of the types above
|
|
47
|
+
export type { IMenuBarMenu, IMenuBarSubmenu, MenuBarTheme, MenuBarUserProfile, MenuBarLinkProps, MenuBarSessionInfoProps } from '@aiquants/menu-bar';
|
|
35
48
|
|
|
36
49
|
// CSS — non-Tailwind hosts load the self-contained standalone build once (Tailwind hosts: see "Styling (CSS)")
|
|
37
50
|
import '@aiquants/menu-bar/styles/menu-bar.standalone.css';
|
|
@@ -41,11 +54,11 @@ import '@aiquants/menu-bar/styles/menu-bar.standalone.css';
|
|
|
41
54
|
|
|
42
55
|
```tsx
|
|
43
56
|
import React, { useState } from 'react';
|
|
44
|
-
import { MenuBar } from '@aiquants/menu-bar';
|
|
57
|
+
import { MenuBar, type Theme } from '@aiquants/menu-bar';
|
|
45
58
|
import '@aiquants/menu-bar/styles/menu-bar.standalone.css';
|
|
46
59
|
|
|
47
60
|
function App() {
|
|
48
|
-
const [theme, setTheme] = useState('light');
|
|
61
|
+
const [theme, setTheme] = useState<Theme>('light');
|
|
49
62
|
|
|
50
63
|
const menuList = [
|
|
51
64
|
{
|
|
@@ -87,6 +100,8 @@ function App() {
|
|
|
87
100
|
}
|
|
88
101
|
```
|
|
89
102
|
|
|
103
|
+
`MenuBar` renders React Router links, so place it inside a router provider.
|
|
104
|
+
|
|
90
105
|
## Props
|
|
91
106
|
|
|
92
107
|
### MenuBar Props
|
|
@@ -98,9 +113,12 @@ function App() {
|
|
|
98
113
|
| `toggleTheme` | `() => void` | Yes | Function to toggle between light and dark themes |
|
|
99
114
|
| `onClickSignout` | `() => void` | Yes | Callback function for sign out action |
|
|
100
115
|
| `menuHeader` | `React.ReactNode` | No | Optional header content for the menu |
|
|
101
|
-
| `
|
|
116
|
+
| `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") |
|
|
117
|
+
| `isMenuhide` | `boolean` | No | Legacy spelling of `isMenuHide`; read only when `isMenuHide` is not given |
|
|
102
118
|
| `onMenuNavigate` | `(payload: MenuNavigationPayload) => void` | No | Callback function triggered when a menu or submenu item is selected |
|
|
103
|
-
| `
|
|
119
|
+
| `isPinned` | `boolean` | No | Controlled pin state. When omitted, the menu bar keeps the pin state itself and stores it in `localStorage` |
|
|
120
|
+
| `pinnedWidth` | `number` | No | Width in pixels reported while pinned (default `240`) |
|
|
121
|
+
| `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
122
|
| `pinnedSubmenuIds` | `number[]` | No | Pinned submenu IDs (controlled) |
|
|
105
123
|
| `initialPinnedSubmenuIds` | `number[]` | No | Initial pinned submenu IDs |
|
|
106
124
|
| `onSubmenuPinChange` | `(pinnedIds: number[]) => void` | No | Callback function triggered when submenu pin states change |
|
|
@@ -114,6 +132,7 @@ interface IMenu {
|
|
|
114
132
|
title: string;
|
|
115
133
|
href?: string;
|
|
116
134
|
submenu: ISubmenu[];
|
|
135
|
+
match?: MenuBarPathMatch; // ignored on a group without href
|
|
117
136
|
}
|
|
118
137
|
```
|
|
119
138
|
|
|
@@ -126,9 +145,18 @@ interface ISubmenu {
|
|
|
126
145
|
href: string;
|
|
127
146
|
mark?: string;
|
|
128
147
|
new?: boolean;
|
|
148
|
+
match?: MenuBarPathMatch;
|
|
129
149
|
}
|
|
130
150
|
```
|
|
131
151
|
|
|
152
|
+
### MenuBarPathMatch Type
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
type MenuBarPathMatch = 'exact' | 'prefix';
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
How an entry's `href` is matched against the current path. See "Current Page Highlighting" below.
|
|
159
|
+
|
|
132
160
|
### MenuNavigationPayload Type
|
|
133
161
|
|
|
134
162
|
```tsx
|
|
@@ -167,17 +195,20 @@ interface LinkProps {
|
|
|
167
195
|
|
|
168
196
|
The package ships **two CSS artifacts** — pick the one that matches your host.
|
|
169
197
|
|
|
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
|
|
198
|
+
- **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
199
|
|
|
172
200
|
```css
|
|
173
201
|
/* app tailwind.css */
|
|
174
202
|
@import "@aiquants/menu-bar/styles/menu-bar.css" layer(components);
|
|
175
|
-
@source "../node_modules/@aiquants/menu-bar/
|
|
176
|
-
/*
|
|
203
|
+
@source "../node_modules/@aiquants/menu-bar/dist/**/*.js";
|
|
204
|
+
/* consuming the package's sources from a workspace: point @source at its src/**/*.{ts,tsx} instead */
|
|
205
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
177
206
|
```
|
|
178
207
|
|
|
179
|
-
`menu
|
|
180
|
-
|
|
208
|
+
The `@custom-variant` line makes the menu bar's `dark:` utilities follow the `theme` prop: the menu bar's root carries `.dark` while `theme` is `'dark'`. With Tailwind's default `dark` variant (`prefers-color-scheme`), they follow the OS setting instead. The variant applies to your whole build, so your own `dark:` utilities then key off a `.dark` ancestor as well.
|
|
209
|
+
|
|
210
|
+
`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.
|
|
211
|
+
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
212
|
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
213
|
|
|
183
214
|
- **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:
|
|
@@ -186,11 +217,53 @@ The package ships **two CSS artifacts** — pick the one that matches your host.
|
|
|
186
217
|
@import "@aiquants/menu-bar/styles/menu-bar.standalone.css";
|
|
187
218
|
```
|
|
188
219
|
|
|
189
|
-
|
|
220
|
+
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.
|
|
221
|
+
|
|
222
|
+
Dark mode follows the `theme` prop: the menu bar's root carries `.dark` while `theme` is `'dark'`, and this build's `dark:` utilities apply to an element with the `.dark` class or inside one, so `<html>` needs no class. A `SessionInfo` rendered outside `MenuBar` takes its dark styles from a `.dark` ancestor of its own. If you also put `.dark` on `<html>`, keep it in step with `theme`: with `theme="light"` it turns on the `dark:` utilities inside the light menu bar while the light colours of the hand-written rules stay, mixing the two palettes.
|
|
223
|
+
|
|
224
|
+
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.
|
|
225
|
+
|
|
226
|
+
## Menu Switch Custom Properties
|
|
227
|
+
|
|
228
|
+
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).
|
|
229
|
+
|
|
230
|
+
| Property | Default | Effect |
|
|
231
|
+
| --- | --- | --- |
|
|
232
|
+
| `--aqmb-switch-position` | `fixed` | `position` of the switch |
|
|
233
|
+
| `--aqmb-switch-top` | `6px` | `top` of the switch |
|
|
234
|
+
| `--aqmb-switch-right` | `8px` | `right` of the switch |
|
|
235
|
+
| `--aqmb-switch-left` | `auto` | `left` of the switch |
|
|
236
|
+
| `--aqmb-switch-bottom` | `auto` | `bottom` of the switch |
|
|
237
|
+
| `--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) |
|
|
238
|
+
| `--aqmb-switch-size` | `27px` | Side S of the switch's square box; every length of the drawing derives from it |
|
|
239
|
+
| `--aqmb-switch-caption-display` | `block` | `display` of the caption; `none` hides it |
|
|
240
|
+
|
|
241
|
+
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.
|
|
242
|
+
|
|
243
|
+
How the drawing follows S:
|
|
244
|
+
|
|
245
|
+
- 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.
|
|
246
|
+
- The caption's top edge is S/2 + 0.5rem below the top of the stack.
|
|
247
|
+
- 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.
|
|
248
|
+
- 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.
|
|
249
|
+
- The default `27px` gives 3px bars spaced 6px apart, a 32px × 4px cross, and a caption 21.5px below the top of the box.
|
|
250
|
+
- 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.
|
|
251
|
+
|
|
252
|
+
Example — fit the switch into a 32px header bar (4px + 24px line box + 4px):
|
|
253
|
+
|
|
254
|
+
```css
|
|
255
|
+
:root {
|
|
256
|
+
--aqmb-switch-size: 24px;
|
|
257
|
+
--aqmb-switch-caption-display: none;
|
|
258
|
+
--aqmb-switch-top: 4px;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
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.
|
|
190
263
|
|
|
191
264
|
## Theming
|
|
192
265
|
|
|
193
|
-
The menu bar supports both light and dark themes.
|
|
266
|
+
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`.
|
|
194
267
|
|
|
195
268
|
### Custom Styling
|
|
196
269
|
|
|
@@ -201,11 +274,11 @@ You can override the default styles by targeting the scoped classes:
|
|
|
201
274
|
/* Custom menu bar styles */
|
|
202
275
|
}
|
|
203
276
|
|
|
204
|
-
.aiquants-menu-bar.
|
|
277
|
+
.aiquants-menu-bar.dark {
|
|
205
278
|
/* Dark theme overrides */
|
|
206
279
|
}
|
|
207
280
|
|
|
208
|
-
.aiquants-menu-bar.
|
|
281
|
+
.aiquants-menu-bar.light {
|
|
209
282
|
/* Light theme overrides */
|
|
210
283
|
}
|
|
211
284
|
```
|
|
@@ -214,10 +287,10 @@ You can override the default styles by targeting the scoped classes:
|
|
|
214
287
|
|
|
215
288
|
```tsx
|
|
216
289
|
import React, { useState } from 'react';
|
|
217
|
-
import { MenuBar } from '@aiquants/menu-bar';
|
|
290
|
+
import { MenuBar, type Theme } from '@aiquants/menu-bar';
|
|
218
291
|
|
|
219
292
|
function App() {
|
|
220
|
-
const [theme, setTheme] = useState('light');
|
|
293
|
+
const [theme, setTheme] = useState<Theme>('light');
|
|
221
294
|
|
|
222
295
|
const menuList = [
|
|
223
296
|
{
|
|
@@ -247,6 +320,89 @@ function App() {
|
|
|
247
320
|
}
|
|
248
321
|
```
|
|
249
322
|
|
|
323
|
+
## Current Page Highlighting
|
|
324
|
+
|
|
325
|
+
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`:
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
const menuList: IMenu[] = [
|
|
329
|
+
{ id: 1, title: 'Home', href: '/', submenu: [] },
|
|
330
|
+
{
|
|
331
|
+
id: 2,
|
|
332
|
+
title: 'Admin',
|
|
333
|
+
submenu: [
|
|
334
|
+
// Also the current entry on /admin/console/search, /admin/console/users/42, ...
|
|
335
|
+
{ subid: 1, title: 'Console', href: '/admin/console', match: 'prefix' },
|
|
336
|
+
{ subid: 2, title: 'Audit log', href: '/admin/audit' },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The rule:
|
|
343
|
+
|
|
344
|
+
- `'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'`.
|
|
345
|
+
- The query, the hash and a trailing slash are ignored on both the `href` and the path.
|
|
346
|
+
- An `href` of `/` matches only the root, even with `'prefix'`. External URLs never match.
|
|
347
|
+
- 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).
|
|
348
|
+
|
|
349
|
+
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.
|
|
350
|
+
|
|
351
|
+
## Pin State
|
|
352
|
+
|
|
353
|
+
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:
|
|
354
|
+
|
|
355
|
+
- **CSS variable**: `--aiquants-menu-pinned-width` on `<html>` holds the pinned width (`pinnedWidth`, default `240`) in pixels while pinned, and `0px` otherwise.
|
|
356
|
+
- **Event**: a `CustomEvent` named by `MENU_PIN_CHANGE_EVENT` (`"aiquants-menu-pin-change"`) on `window`. Its `detail` is a `MenuPinChangeDetail`, `{ pinned, width, active? }`: `width` matches the CSS variable, and `active` tells whether the drawer is open or pinned.
|
|
357
|
+
|
|
358
|
+
These exports read the state:
|
|
359
|
+
|
|
360
|
+
| Export | Behaviour |
|
|
361
|
+
| --- | --- |
|
|
362
|
+
| `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` |
|
|
363
|
+
| `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`) |
|
|
364
|
+
| `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) |
|
|
365
|
+
| `dispatchMenuPinChange(pinned, width, active = pinned)` | Dispatches the event on `window`. The menu bar calls it itself |
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
import type { ReactNode } from 'react';
|
|
369
|
+
import { useMenuPinState } from '@aiquants/menu-bar';
|
|
370
|
+
|
|
371
|
+
function Page({ children }: { children: ReactNode }) {
|
|
372
|
+
const { width } = useMenuPinState();
|
|
373
|
+
// The pinned drawer covers the right edge of the viewport
|
|
374
|
+
return <main style={{ paddingRight: width }}>{children}</main>;
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Accessibility
|
|
379
|
+
|
|
380
|
+
- **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`).
|
|
381
|
+
- **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.
|
|
382
|
+
- **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.
|
|
383
|
+
- **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.
|
|
384
|
+
- **Current page**: the matched entry's link carries `aria-current="page"`.
|
|
385
|
+
- **Groups**: group summaries toggle with Enter or Space and show a focus outline on keyboard focus. Every button has an accessible name.
|
|
386
|
+
- **Reduced motion**: the drawer's slide, the backdrop's fade and the switch's transitions run only under `prefers-reduced-motion: no-preference`.
|
|
387
|
+
- **Forced colors**: the switch's bars are drawn in `ButtonText`, so they stay visible when forced colors replace backgrounds.
|
|
388
|
+
- **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 `SessionInfo` shows Japanese texts such as "プロフィール" and "ログインしてください".
|
|
389
|
+
|
|
390
|
+
## DOM Hooks
|
|
391
|
+
|
|
392
|
+
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.
|
|
393
|
+
|
|
394
|
+
| Attribute | Element |
|
|
395
|
+
| --- | --- |
|
|
396
|
+
| `data-aqmb-menu-switch` | The open/close switch button |
|
|
397
|
+
| `data-aqmb-menu-content` | The drawer's root element |
|
|
398
|
+
| `data-aqmb-menu-overlay` | The backdrop |
|
|
399
|
+
| `data-aqmb-menu-list` | The scroll container of the menu list |
|
|
400
|
+
| `data-aqmb-pin-button` | The drawer's pin button |
|
|
401
|
+
| `data-aqmb-theme-button` | The theme toggle button |
|
|
402
|
+
| `data-aqmb-signout-button` | The sign-out button |
|
|
403
|
+
| `data-aqmb-submenu-pin-button` | The pin button of each group |
|
|
404
|
+
| `data-aqmb-session-card` | The `SessionInfo` card, with or without a `user` |
|
|
405
|
+
|
|
250
406
|
## Advanced Usage
|
|
251
407
|
|
|
252
408
|
### With Menu Header
|
|
@@ -280,7 +436,7 @@ import React from 'react';
|
|
|
280
436
|
theme={theme}
|
|
281
437
|
toggleTheme={toggleTheme}
|
|
282
438
|
onClickSignout={handleSignout}
|
|
283
|
-
|
|
439
|
+
isMenuHide={shouldHideMenu}
|
|
284
440
|
/>
|
|
285
441
|
```
|
|
286
442
|
|
|
@@ -290,19 +446,21 @@ import React from 'react';
|
|
|
290
446
|
|
|
291
447
|
The main menu bar component that includes:
|
|
292
448
|
|
|
293
|
-
-
|
|
294
|
-
-
|
|
295
|
-
-
|
|
296
|
-
-
|
|
297
|
-
-
|
|
449
|
+
- Open/close switch with four bars that turn into a cross
|
|
450
|
+
- Header slot (`menuHeader`) and navigation menu
|
|
451
|
+
- Pin, theme toggle and sign-out buttons
|
|
452
|
+
- Backdrop that closes the drawer when pressed
|
|
453
|
+
- Focus management and current page highlighting
|
|
298
454
|
|
|
299
455
|
### SessionInfo
|
|
300
456
|
|
|
301
457
|
A standalone session information component that displays:
|
|
302
458
|
|
|
303
|
-
-
|
|
304
|
-
-
|
|
305
|
-
-
|
|
459
|
+
- The user's avatar (a default icon when `photo` is empty), display name and a "Google Account" label
|
|
460
|
+
- Two links rendered through the customizable `LinkComponent` (default: `DefaultLink`, a plain `<a>`): a profile link to `/` and a link to `https://myaccount.google.com/`
|
|
461
|
+
- A "please sign in" card ("ログインしてください") when `user` is `null`
|
|
462
|
+
|
|
463
|
+
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`.
|
|
306
464
|
|
|
307
465
|
```tsx
|
|
308
466
|
import { SessionInfo } from '@aiquants/menu-bar';
|
|
@@ -322,42 +480,56 @@ import { SessionInfo } from '@aiquants/menu-bar';
|
|
|
322
480
|
|
|
323
481
|
- **Hierarchical Menu Structure**: Support for main menu items with submenus
|
|
324
482
|
- **Dynamic Navigation**: Menu items can have direct links or submenu collections
|
|
325
|
-
- **
|
|
326
|
-
- **
|
|
483
|
+
- **Current Page**: Exact or prefix matching per entry, with `aria-current="page"`
|
|
484
|
+
- **Visual Indicators**: An icon per submenu `mark` (`report`, `slip`, `table`, `chart`, `download`, `lock`, `href`); any other value, or none, renders the text only
|
|
485
|
+
- **External Links**: `new: true` opens a submenu item in a new tab (`target="_blank"`, `rel="noopener noreferrer"`); it draws no badge
|
|
327
486
|
|
|
328
487
|
## CSS Classes
|
|
329
488
|
|
|
330
|
-
|
|
489
|
+
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:
|
|
331
490
|
|
|
332
|
-
- `.aiquants-menu-bar` - Main container
|
|
333
|
-
- `.aiquants-menu-switch` -
|
|
334
|
-
- `.aiquants-menu-content` -
|
|
335
|
-
- `.aiquants-
|
|
336
|
-
- `.aiquants-
|
|
491
|
+
- `.aiquants-menu-bar` - Main container (with the theme class `light` or `dark`)
|
|
492
|
+
- `.aiquants-menu-switch` - Open/close switch (`.aiquants-menu-switch-bars`, `.aiquants-menu-switch-bar`, `.aiquants-menu-switch-caption`)
|
|
493
|
+
- `.aiquants-menu-content` - Drawer
|
|
494
|
+
- `.aiquants-menu-list-item` - Menu item and group summary
|
|
495
|
+
- `.aiquants-session-card` - Session information card
|
|
496
|
+
|
|
497
|
+
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.
|
|
498
|
+
|
|
499
|
+
These classes are a styling contract, not DOM hooks — use the `data-aqmb-*` attributes above to select elements.
|
|
337
500
|
|
|
338
501
|
## Browser Support
|
|
339
502
|
|
|
340
|
-
- Chrome/Edge
|
|
341
|
-
- Firefox
|
|
342
|
-
- Safari
|
|
343
|
-
|
|
503
|
+
- Chrome/Edge 111+
|
|
504
|
+
- Firefox 128+
|
|
505
|
+
- Safari 16.4+
|
|
506
|
+
|
|
507
|
+
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.
|
|
344
508
|
|
|
345
509
|
## Development
|
|
346
510
|
|
|
347
511
|
```bash
|
|
348
512
|
# Install dependencies
|
|
349
|
-
|
|
513
|
+
pnpm install
|
|
350
514
|
|
|
351
515
|
# Build the package
|
|
352
|
-
|
|
516
|
+
pnpm run build
|
|
353
517
|
|
|
354
518
|
# Run type checking
|
|
355
|
-
|
|
519
|
+
pnpm run typecheck
|
|
520
|
+
|
|
521
|
+
# Run the unit tests (Vitest, jsdom)
|
|
522
|
+
pnpm run test
|
|
523
|
+
|
|
524
|
+
# Run the browser tests (Playwright, Chromium) against a local harness page
|
|
525
|
+
pnpm run test:e2e
|
|
356
526
|
|
|
357
527
|
# Clean build files
|
|
358
|
-
|
|
528
|
+
pnpm run clean
|
|
359
529
|
```
|
|
360
530
|
|
|
531
|
+
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.
|
|
532
|
+
|
|
361
533
|
## License
|
|
362
534
|
|
|
363
535
|
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,13 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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";
|