@aiquants/menu-bar 1.24.0 → 1.25.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/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.25.0 (2026-11-10)
4
+
5
+ ### Added
6
+
7
+ - **`tabBar` draws a bottom tab bar in place of the switch.** While the host's media query (`tabBar.media`, for
8
+ example `"(max-width: 639px)"`) matches, a `<nav>` named "Tab bar" runs along the viewport's bottom edge with links to
9
+ 1 to 4 menu entries (`tabBar.tabs`: each names an entry by its path and has a short label and an icon), followed by a
10
+ menu tab that opens and closes the drawer; the switch is not drawn meanwhile. The menu bar writes the media rules
11
+ into a `<style>` element of its own (with `tabBar.nonce` for a Content Security Policy), so the bar or the switch is
12
+ drawn right from the server's HTML. The current entry's tab carries `aria-current="page"` (the menu tab looks
13
+ selected when that entry has no tab); the bar stays above the open drawer, which ends at the bar's top edge; the
14
+ drawer cannot be pinned while the bar is drawn; and while a text field holds the focus, neither the bar nor the switch
15
+ is drawn. Rendering throws on a tab bar the menu bar cannot draw: no tab or more than four, an empty label, a path
16
+ that names no entry or names one entry twice, and a media query that is empty, holds a comment or `</`, or holds a
17
+ character outside its set. New types `MenuBarTabBar` and `MenuBarTab`, a new custom property `--aqmb-tab-bar-z-index`, and new
18
+ DOM hooks `data-aqmb-tab-bar`, `data-aqmb-tab`, `data-aqmb-tab-menu` and `data-aqmb-current`.
19
+ - **`pinnable={false}` turns the drawer's pin off.** The footer then has no pin button, the menu bar reports "not
20
+ pinned" (`--aiquants-menu-pinned-width: 0px` and the event), and the drawer opens and closes with the backdrop
21
+ without clearing the stored pin or calling `onPinChange`; the stored pin (or a controlled `isPinned`) applies again
22
+ once the value returns to `true`. A pinned drawer that turns unpinnable closes, keeping its pin, and moves the focus
23
+ it holds to the switch.
24
+ - **The menu bar reports the room its switch and its tab bar take.** On `:root`, `--aqmb-switch-clearance-inline-end`
25
+ and `--aqmb-switch-clearance-block-start` hold the switch's inset, box and focus ring from the top right corner while
26
+ the switch is drawn, and `--aqmb-tab-bar-block-size` holds the tab bar's height (3.5rem plus the bottom safe area at
27
+ the default type scale) while the bar is drawn; each is `0px` otherwise.
28
+
29
+ ### Changed
30
+
31
+ - **The short-drawer layout queries the drawer's own height.** The rule that makes the whole drawer scroll as one at or
32
+ below 16.8125rem moved from `@media (max-height: 16.8125rem)` to a container query on the drawer
33
+ (`.aiquants-menu-content` is now a size container named `aqmb-menu-drawer`), so it also applies to a drawer the tab
34
+ bar shortens. Its rem now resolves against the root element's font size, as the drawer's parts do, instead of the
35
+ browser's default font size: on a page that sets its own root font size, the threshold moves with it. On a page that
36
+ leaves the root font size alone, a drawer as tall as the viewport behaves as before. The drawer's height is now a
37
+ definite `block-size: 100dvh` in the stylesheet (less the tab bar's height while the bar is drawn) in place of the
38
+ `min-h-dvh` utility and `max-block-size: 100dvh`. The drawn size is the same; the query needs a definite height,
39
+ because Chromium reads a size container whose height comes from a minimum height alone as 0px tall.
40
+ - **Every text of the drawer is at least 7:1 against what is behind it (WCAG 1.4.6, AAA).** The icons of the buttons
41
+ without text, the marks and the focus outlines are at least 3:1 (WCAG 1.4.11), in both themes and in every state (at
42
+ rest, under the pointer, pinned and focused). In 1.24.0 the light theme's links were 3.09:1 on their rows and the
43
+ dark theme's 4.83:1, the light theme's sign-out label 4.41:1, and its group pin buttons' icons 1.80:1. The colours
44
+ that change:
45
+ - Light theme:
46
+ - The drawer's background is sky-800 (was sky-700), the tab bar's colour.
47
+ - The session card is an opaque sky-900, sky-950 under the pointer (was translucent white). Its links are sky-800,
48
+ sky-950 under the pointer, and its "Google Account" label sky-100 (was white at 75 %).
49
+ - The group headings' text is gray-700 (was gray-600).
50
+ - The links are sky-900 (were sky-600).
51
+ - The group pin buttons are gray-500, gray-700 under the pointer, and amber-700 / amber-800 while pinned (were
52
+ translucent gray, and amber-600 / amber-700 while pinned).
53
+ - Dark theme:
54
+ - The links are sky-200 (were sky-400).
55
+ - The session card's "Google Account" label is slate-300 (was slate-400), and its links' labels slate-100 (were
56
+ slate-200).
57
+ - The group pin buttons are slate-400, slate-200 under the pointer (were translucent white).
58
+ - Both themes:
59
+ - The sign-out button is red-800, red-900 under the pointer (was translucent red-500 or red-600).
60
+ - The pinned pin button is amber-700, amber-800 under the pointer (was amber-500 or amber-600).
61
+ - The footer buttons draw their keyboard focus as a 2px outline in their text colour (white) at a 2px offset. It
62
+ was a translucent `box-shadow` ring, which forced colors removed.
63
+ - The drawer's own text colour is white, so a header a host passes without a colour of its own is readable
64
+ (it took the page's colour).
65
+ - A group link's text takes its colour from the stylesheet's `.aiquants-menu-link` rule with or without a `mark` (an
66
+ entry without one took the `text-sky-600` / `dark:text-sky-400` utilities), and the mark's icon takes it too (it
67
+ took the host's or the user agent's link colour).
68
+ - **The stylesheet no longer has the `.aiquants-signout-button` background rules.** The footer's utilities always
69
+ overrode them (the stylesheet sits in the `components` layer, below `utilities`), so the button looks as before; the
70
+ class stays as a styling hook.
71
+
72
+ ### Fixed
73
+
74
+ - **A stored pin no longer makes hydration fail.** In a server-rendered host the server cannot read `localStorage`, so
75
+ it draws the drawer and the groups unpinned, but the render that hydrated read the stored pins
76
+ (`aiquants-menu-pinned`, `aiquants-submenu-pinned`) and drew them pinned. That render did not match the server's
77
+ HTML (the switch's caption, the drawer's and the group's state), so React reported a recoverable hydration error,
78
+ threw the server's HTML away up to the nearest `<Suspense>` boundary (the whole page without one) and rendered it
79
+ again on the client. The menu bar now draws the hydration render as the server did and applies the stored pins in
80
+ the render right after it, so React keeps the server's HTML. A render on the client alone still reads them in its
81
+ first render.
82
+
83
+ ### Documentation
84
+
85
+ - The README gains "Bottom Tab Bar" and "Reported Sizes", the `pinnable` and `tabBar` props and the `MenuBarTabBar`
86
+ type, the pin state while the drawer cannot be pinned and at hydration, and the tab bar in "Accessibility", "DOM
87
+ Hooks", "CSS Classes" and "Browser Support"; "Drawer Layout and Scrolling" describes the container query, and "Text
88
+ Size" the caption step of the tab bar's labels. "Accessibility" gains "Contrast", and its "Forced colors" names the
89
+ footer buttons' focus outlines.
90
+
3
91
  ## 1.24.0 (2026-11-10)
4
92
 
5
93
  ### Changed
package/README.md CHANGED
@@ -8,8 +8,9 @@ A React menu bar component with authentication support and theming for modern we
8
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
9
9
  - **Dark/Light Theme**: Comprehensive theming support with scoped CSS
10
10
  - **Session Management**: Built-in session information display with user profiles
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
11
+ - **Pinning**: Keep the drawer open while navigating, with the pinned width exposed as a CSS variable and an event; a host can turn pinning off (`pinnable`), and in a server-rendered host a stored pin applies right after hydration
12
+ - **Bottom Tab Bar**: On a layout the host names by a media query (a phone, for one), up to four tabs for menu entries and a menu tab run along the viewport's bottom edge in place of the switch, drawn right from the server's HTML
13
+ - **Accessible**: Named switch and menu tab with `aria-expanded`, an inert closed drawer, focus that returns to the switch or the menu tab, visible focus rings, reduced-motion and forced-colors support
13
14
  - **Scroll Containment**: The wheel or a touch drag over the open drawer, or over the backdrop, does not scroll the page behind it (where nothing under the pointer can scroll, this needs Chrome/Edge 144+ or Firefox 150+; see "Drawer Layout and Scrolling")
14
15
  - **Configurable Switch**: Position, size, layering and caption of the open/close switch set through CSS custom properties
15
16
  - **TypeScript Support**: Full TypeScript definitions included
@@ -39,6 +40,7 @@ export { useMenuPinState, subscribeMenuPinChange, dispatchMenuPinChange, getInit
39
40
  // Types
40
41
  export type { IMenuBarProps } from '@aiquants/menu-bar';
41
42
  export type { IMenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from '@aiquants/menu-bar';
43
+ export type { MenuBarTab, MenuBarTabBar } from '@aiquants/menu-bar';
42
44
  export type { Theme } from '@aiquants/menu-bar';
43
45
  export type { SessionInfoProps, LinkProps } from '@aiquants/menu-bar';
44
46
  export type { UserProfile } from '@aiquants/menu-bar';
@@ -119,11 +121,13 @@ function App() {
119
121
  | `onMenuNavigate` | `(payload: MenuNavigationPayload) => void` | No | Callback function triggered when a menu or submenu item is selected |
120
122
  | `isPinned` | `boolean` | No | Controlled pin state. When omitted, the menu bar keeps the pin state itself and stores it in `localStorage` |
121
123
  | `pinnedWidth` | `number` | No | Width in pixels reported while pinned (default `240`) |
122
- | `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) |
124
+ | `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, nor while the drawer cannot be pinned) |
123
125
  | `pinnedSubmenuIds` | `number[]` | No | Pinned submenu IDs (controlled) |
124
126
  | `initialPinnedSubmenuIds` | `number[]` | No | Initial pinned submenu IDs |
125
127
  | `onSubmenuPinChange` | `(pinnedIds: number[]) => void` | No | Callback function triggered when submenu pin states change |
126
128
  | `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 |
129
+ | `pinnable` | `boolean` | No | Whether the drawer can be pinned (default `true`). While `false`, the footer has no pin button and the drawer opens and closes with the backdrop; the menu bar reports "not pinned", keeps the stored pin (or the controlled `isPinned`) and applies it again once the value returns to `true` (see "Pin State") |
130
+ | `tabBar` | `MenuBarTabBar` | No | A bottom tab bar drawn in place of the switch while its media query matches; meanwhile the drawer cannot be pinned (see "Bottom Tab Bar"). Rendering throws on a tab bar the menu bar cannot draw |
127
131
 
128
132
  ### IMenu Type
129
133
 
@@ -167,6 +171,24 @@ type MenuNavigationPayload = {
167
171
  };
168
172
  ```
169
173
 
174
+ ### MenuBarTabBar Type
175
+
176
+ ```tsx
177
+ interface MenuBarTabBar {
178
+ media: string; // While this media query matches, the bar is drawn in place of the switch
179
+ tabs: readonly MenuBarTab[]; // 1 to 4 tabs, in their order; the menu tab follows them
180
+ nonce?: string; // The nonce of the <style> element that carries the bar's media rules
181
+ }
182
+
183
+ interface MenuBarTab {
184
+ href: string; // The path of the menu entry the tab links to
185
+ label: string; // The tab's visible label and accessible name, a short word
186
+ icon: ReactNode; // Drawn above the label, hidden from assistive technology
187
+ }
188
+ ```
189
+
190
+ See "Bottom Tab Bar" for how the tabs are matched and what is refused.
191
+
170
192
  ### UserProfile Type
171
193
 
172
194
  ```tsx
@@ -288,15 +310,38 @@ Example — let the closed switch scroll away with the page. A `fixed` switch fl
288
310
  Render `MenuBar` at the top of the page across its full width (for example as the first child of `<body>`), so the anchored switch stands where the floating one would before the page scrolls, and reserve its room at the top of the page only (for example the end padding of your header). Once the page has scrolled, the closed switch is above the window, and the user scrolls back to reach it.
289
311
  While the drawer is open or pinned, the switch stays at the viewport's corner on top of the drawer. While it holds the keyboard focus (after a key closes the drawer, or when Tab reaches it), it stays there too, so the focus never leaves the window and reaching the switch never scrolls the page back to the top. A pointer press does not count as keyboard focus, so after one the closed switch goes back to the root at once.
290
312
 
313
+ ### Reported Sizes
314
+
315
+ The menu bar reports how much of the viewport's edges its switch and its tab bar take, in three custom properties on `:root`, so a header or the page can keep clear of them. The package sets them: read them, and never declare them.
316
+
317
+ | Property | While drawn | Otherwise |
318
+ | --- | --- | --- |
319
+ | `--aqmb-switch-clearance-inline-end` | `--aqmb-switch-right` + B + 4px: the switch's inset, its box and its focus ring, from the right edge | `0px` (no switch in the document, or the tab bar drawn in its place) |
320
+ | `--aqmb-switch-clearance-block-start` | `--aqmb-switch-top` + B + 4px, from the top edge. The caption that hangs below a box shorter than the column of bars and caption is not counted | `0px`, as above |
321
+ | `--aqmb-tab-bar-block-size` | The tab bar's height, from the bottom edge (see "Bottom Tab Bar") | `0px` (no tab bar, its media query not matching, or a text field holding the focus) |
322
+
323
+ The switch's clearances are computed on `:root`, from the switch properties as they are there, and measure from the corner the switch's insets measure from: the viewport's top right corner, or the menu bar root's with `--aqmb-switch-position: absolute`. They assume the switch sits at that corner (`--aqmb-switch-left` and `--aqmb-switch-bottom` left `auto`).
324
+
325
+ ```css
326
+ header {
327
+ padding-inline-end: var(--aqmb-switch-clearance-inline-end);
328
+ }
329
+
330
+ main {
331
+ padding-block-end: var(--aqmb-tab-bar-block-size);
332
+ }
333
+ ```
334
+
291
335
  ## Drawer Layout and Scrolling
292
336
 
293
337
  Under every pointer (a mouse, a pen or a finger) the footer's pin, theme and sign-out buttons and the links of `SessionInfo` are at least 2.75rem × 2.75rem (44 × 44 px at the default root font size, the WCAG 2.5.5 target). The list's rows are 2.25rem tall (36px: one 1.25rem body line with 0.5rem above and below, past the 24px minimum of WCAG 2.5.8): every link of the list fills its row (the rows have no block padding of their own), and the group headings, the links inside a group and the group headings' pin buttons (2.25rem × 2.25rem) are as tall.
294
338
 
295
- The drawer is never taller than the viewport. On a viewport at most 16.8125rem tall (269px at the default font size: the signed-in `SessionInfo` header, the footer and two rows of the list), the header, the list and the footer scroll together as one, so nothing is cut off or out of reach at high zoom (for example 360 × 225 CSS px, a 1440 × 900 window at 400 %); on taller viewports the header and the footer stay in place and only the list scrolls.
296
- The threshold is in rem, so it rises with the browser's default font-size setting as the drawer's parts do. With a header of your own that is taller than `SessionInfo`, the drawer still scrolls as a whole once the header and the footer alone no longer fit.
339
+ The drawer is as tall as the viewport, or reaches down to the tab bar's top edge while the tab bar is drawn. In a drawer at most 16.8125rem tall (269px at the default font size: the signed-in `SessionInfo` header, the footer and two rows of the list), the header, the list and the footer scroll together as one, so nothing is cut off or out of reach at high zoom (for example on 360 × 225 CSS px, a 1440 × 900 window at 400 %); in a taller drawer the header and the footer stay in place and only the list scrolls.
340
+ The threshold is a container query on the drawer's own height (the drawer is a size container named `aqmb-menu-drawer`), so it follows the drawer wherever its height comes from, the tab bar included.
341
+ The stylesheet gives the drawer a definite height (`block-size`); a rule of yours that sizes the drawer through `min-height` alone would hide that height from the query in Chromium, which reads such a container as 0px tall and so takes every drawer for a short one. Its rem resolves against the root element's font size, the one the drawer's parts are sized in, so it rises with the browser's font-size setting and with a root font size the page sets. With a header of your own that is taller than `SessionInfo`, the drawer still scrolls as a whole once the header and the footer alone no longer fit.
297
342
 
298
- Scrolling over the drawer stays in the drawer. The drawer and the list's scroll container are boundaries of scroll chaining (`overscroll-behavior: contain`), and so is the backdrop while it is shown (a scroll container with nothing to scroll): the wheel or a touch drag over the open drawer, overlaid or pinned, scrolls the list (on a short viewport, the whole drawer) and stops at its ends instead of moving on to the page behind it, and over the backdrop it scrolls nothing.
299
- Where nothing under the pointer can scroll (a list that fits, the header or the footer on a tall viewport, the backdrop), this needs a browser that treats a scroll container with nothing to scroll as a boundary: Chrome/Edge 144+ and Firefox 150+ do, Safari does not ([WebKit bug 243452](https://bugs.webkit.org/show_bug.cgi?id=243452)) and scrolls the page there. The switch is not part of the drawer, so the wheel over it scrolls the page.
343
+ Scrolling over the drawer stays in the drawer. The drawer and the list's scroll container are boundaries of scroll chaining (`overscroll-behavior: contain`), and so is the backdrop while it is shown (a scroll container with nothing to scroll): the wheel or a touch drag over the open drawer, overlaid or pinned, scrolls the list (in a short drawer, the whole drawer) and stops at its ends instead of moving on to the page behind it, and over the backdrop it scrolls nothing.
344
+ Where nothing under the pointer can scroll (a list that fits, the header or the footer of a tall drawer, the backdrop), this needs a browser that treats a scroll container with nothing to scroll as a boundary: Chrome/Edge 144+ and Firefox 150+ do, Safari does not ([WebKit bug 243452](https://bugs.webkit.org/show_bug.cgi?id=243452)) and scrolls the page there. The switch and the tab bar are not part of the drawer, so the wheel over them scrolls the page.
300
345
 
301
346
  ## Text Size
302
347
 
@@ -306,8 +351,8 @@ The drawer's controls and list rows draw their text on two steps of a type scale
306
351
  | --- | --- | --- |
307
352
  | `--aqmb-font-size-body` | `0.8125rem` (13px at the default root font size) | The primary text of controls and rows: the footer's pin, theme and sign-out buttons, the list's links and group headings, and the links inside a group. From a viewport 40rem wide up, the list's top-level links and group headings use `text-sm` (0.875rem, 14px) instead |
308
353
  | `--aqmb-line-height-body` | `1.25rem` (20px) | The line of that text |
309
- | `--aqmb-font-size-caption` | `0.6875rem` (11px) | The links and supplementary "Google Account" label of `SessionInfo` |
310
- | `--aqmb-line-height-caption` | `1.25rem` (20px) | The line of that text |
354
+ | `--aqmb-font-size-caption` | `0.6875rem` (11px) | The links and supplementary "Google Account" label of `SessionInfo`, and the tab bar's labels |
355
+ | `--aqmb-line-height-caption` | `1.25rem` (20px) | The line of that text. The tab bar's height follows it as bound on `:root` (see "Bottom Tab Bar") |
311
356
 
312
357
  The package only reads these properties and never declares them, so a host can bind them on `:root`, for example to its own type scale:
313
358
 
@@ -437,17 +482,58 @@ function Page({ children }: { children: ReactNode }) {
437
482
  }
438
483
  ```
439
484
 
485
+ While the drawer cannot be pinned (`pinnable` is `false`, or the tab bar is drawn), the footer has no pin button and the menu bar reports "not pinned": `--aiquants-menu-pinned-width` is `0px` and the event says `pinned: false`. The drawer opens and closes with the backdrop, and closing it neither clears the stored pin nor calls `onPinChange`. A pinned drawer that can no longer be pinned closes, keeping its pin, and moves the focus it holds to the switch or the menu tab; once the drawer can be pinned again, the stored pin (or the controlled `isPinned`) applies again and the drawer opens pinned.
486
+
487
+ The server cannot read `localStorage`, so a server-rendered menu bar draws the drawer and the groups unpinned, and so does the render that hydrates it. The stored pins (the `aiquants-menu-pinned` and `aiquants-submenu-pinned` keys) apply in the render right after, so hydration keeps the server's HTML. A render on the client alone reads them in its first render.
488
+
489
+ ## Bottom Tab Bar
490
+
491
+ On a layout you name by a media query (a phone, for one), the menu bar can draw a bar of tabs along the viewport's bottom edge in place of the switch: links to up to four of the menu's entries, followed by a menu tab that opens and closes the drawer.
492
+
493
+ ```tsx
494
+ import { MenuBar, type MenuBarTabBar } from '@aiquants/menu-bar';
495
+ import { HomeIcon, ReportIcon } from './icons'; // any icon elements
496
+
497
+ const tabBar: MenuBarTabBar = {
498
+ media: '(max-width: 639px)',
499
+ tabs: [
500
+ { href: '/dashboard', label: 'Home', icon: <HomeIcon /> },
501
+ { href: '/data/reports', label: 'Reports', icon: <ReportIcon /> },
502
+ ],
503
+ };
504
+
505
+ <MenuBar menuList={menuList} tabBar={tabBar} theme={theme} toggleTheme={toggleTheme} onClickSignout={handleSignout} />
506
+ ```
507
+
508
+ - **Bar or switch**: while `media` matches, the bar is drawn and the switch is not; otherwise only the switch is. The menu bar writes the rules for this into a `<style>` element it renders, outside any cascade layer (pass `nonce` when a Content Security Policy guards styles), so the right one is drawn from the server's HTML on, and nothing appears or leaves when the page hydrates. While a text field of the page holds the focus (an on-screen keyboard may be up), neither is drawn.
509
+ - **Tabs**: a tab names a menu entry by its path, matched the way the current page is (the query, the hash and a trailing slash are ignored): a top-level item's or a submenu item's `href`. The tab links to the entry's own `href`, query included, the way the drawer's list renders the entry (a router link; a plain `<a>` for an `http(s)` URL or an `.html` / `.htm` path; a new tab for a submenu item with `new: true`). Selecting it calls `onMenuNavigate` with the entry's payload and closes the drawer.
510
+ - **Labels and icons**: `label` is the tab's visible text, at the caption step (see "Text Size"), and its accessible name. Pass a short word: the label stays on one line, and one too wide for its tab is cut with an ellipsis (its accessible name stays whole). `icon` is drawn in a 1.5rem box above the label and hidden from assistive technology.
511
+ - **What is refused**: rendering throws when `tabs` holds fewer than 1 or more than 4 tabs, a label is empty or white space, a tab's `href` names no entry of the menu, or two tabs name the same entry, and when `media` is empty, holds a comment or `</`, or holds a character other than letters, digits, white space and `( ) , : . + - * / < > = _`.
512
+ - **Current tab**: the tab of the entry the current path belongs to (see "Current Page Highlighting") carries `aria-current="page"`; when that entry has no tab, or no entry matches, the menu tab looks selected instead. Either is bold under an indicator along its top edge, drawn in `Highlight` under forced colors.
513
+ - **Menu tab and drawer**: the menu tab is a button named "Open menu" / "Close menu" with `aria-expanded` and `aria-controls` naming the drawer. While the drawer is open, the bar stays above the drawer and the backdrop, so every tab can still be pressed, and the drawer ends at the bar's top edge, so its footer stays in view. When the drawer closes around the focus, the focus moves to the menu tab.
514
+ - **No pinning meanwhile**: while the bar is drawn, the drawer cannot be pinned (see "Pin State"). A drawer pinned on a wider layout closes when the bar appears, keeping its pin, and opens pinned again when the bar goes.
515
+ - **Size**: the bar is 3.5rem tall (56px at the default root font size: a 2.75rem target with 0.375rem above and below it) plus the bottom safe area, which is non-zero only with `viewport-fit=cover` in the page's viewport meta tag.
516
+ With a caption line taller than the icon leaves room for (`--aqmb-line-height-caption` above 1.25rem, bound on `:root`), the target grows to the icon's 1.5rem plus the line. The tabs share the bar's width equally (with the menu tab, five tabs are each about 62px wide on a 320px viewport, and at least 44px on a viewport 240px wide or wider), and their 2px focus rings stay inside the bar. The bar does not animate.
517
+ - **Reported height**: `--aqmb-tab-bar-block-size` on `:root` is the bar's height while it is drawn and `0px` otherwise (see "Reported Sizes"); pad the page's end with it.
518
+ - **Layering**: `--aqmb-tab-bar-z-index` (default `calc(infinity)`, as for the switch) is the closed bar's `z-index`; lower it to let a dialog of your own cover the bar. While the drawer is open, the bar is at 2147483643, above the drawer, whatever the value.
519
+
440
520
  ## Accessibility
441
521
 
442
522
  - **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`), and the switch stays in the window while it holds the keyboard focus, even where `--aqmb-switch-position: absolute` lets the closed switch scroll away with the page.
443
- - **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.
444
- - **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.
445
- - **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.
446
- - **Current page**: the matched entry's link carries `aria-current="page"`.
523
+ - **Tab bar**: a `<nav>` named "Tab bar". Its tabs are links, the current one with `aria-current="page"`; the menu tab is a native `<button>` named "Open menu" / "Close menu" with `aria-expanded` and `aria-controls` naming the drawer, its caption `aria-hidden`. Every tab is at least 2.75rem × 2.75rem on a viewport 240px wide or wider, its 2px focus ring stays inside the bar, and its text is at least 7:1 against the bar (white on sky-800, or on slate-900 in the dark theme). The current tab is told by its weight and an indicator, not by colour alone.
524
+ - **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 (or, while the tab bar is drawn, the bar's tabs) is the menu bar's only Tab stop. A pinned drawer stays open and interactive. This relies on the HTML `inert` attribute.
525
+ - **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`), the switch or the menu tab.
526
+ - **Focus return**: when the drawer closes — by the switch or the menu tab, the backdrop, Escape, selecting an item or a tab while not pinned, or a pinned drawer that can no longer be pinned — with the focus inside it, the focus moves to the switch, or to the menu tab while the tab bar is drawn (a text field holding the focus gives it up first, so that the bar is drawn again to take it). A focus outside the drawer stays where it is.
527
+ - **Current page**: the matched entry's link carries `aria-current="page"`, and so does its tab in the tab bar.
447
528
  - **Groups**: group summaries toggle with Enter or Space and show a focus outline on keyboard focus. Every button has an accessible name.
448
- - **Reduced motion**: the drawer's slide, the backdrop's fade and the switch's transitions run only under `prefers-reduced-motion: no-preference`.
449
- - **Forced colors**: the switch's bars are drawn in `ButtonText`, so they stay visible when forced colors replace backgrounds.
450
- - **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 "ログインしてください".
529
+ - **Contrast**: every text of the drawer, of `SessionInfo` in it and of the tab bar is at least 7:1 against what is behind it (WCAG 1.4.6, AAA), and the icons of the buttons without text, the marks and the focus outlines are at least 3:1 (WCAG 1.4.11), in both themes and in every state: at rest, under the pointer, pinned and focused.
530
+ - The light theme draws the drawer and the bar on sky-800, the dark theme on slate-900, and the drawer's own text colour is white, so a `menuHeader` without a colour of its own is readable too.
531
+ - The light theme's session card is an opaque sky-900, so it keeps its ratios wherever you place it; the dark theme's card is translucent and is counted over the drawer.
532
+ - The theme button's emoji keeps its own colours. Colours you set through the classes of "CSS Classes" are yours to check.
533
+ - **Footer focus**: the footer's pin, theme and sign-out buttons show a 2px outline in their text colour (white) at a 2px offset on keyboard focus, drawn on the drawer's background.
534
+ - **Reduced motion**: the drawer's slide, the backdrop's fade and the switch's transitions run only under `prefers-reduced-motion: no-preference`; the tab bar does not animate.
535
+ - **Forced colors**: the switch's bars are drawn in `ButtonText`, and the current tab's indicator in `Highlight`, so they stay visible when forced colors replace backgrounds. Every focus ring of the menu bar is an outline, which forced colors keep.
536
+ - **Built-in labels**: the names and texts are fixed, and no prop localises them. The switch and the menu tab are named in English ("Open menu" / "Close menu", caption "menu" / "close"), and so is the tab bar ("Tab bar"); 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 "ログインしてください".
451
537
 
452
538
  ## DOM Hooks
453
539
 
@@ -464,6 +550,10 @@ Select the menu bar's elements in scripts and tests by these attributes (or by r
464
550
  | `data-aqmb-signout-button` | The sign-out button |
465
551
  | `data-aqmb-submenu-pin-button` | The pin button of each group |
466
552
  | `data-aqmb-session-card` | The `SessionInfo` card, with or without a `user` |
553
+ | `data-aqmb-tab-bar` | The tab bar (`<nav>`) |
554
+ | `data-aqmb-tab` | Each of your tabs in the tab bar (a link) |
555
+ | `data-aqmb-tab-menu` | The tab bar's menu tab (a button) |
556
+ | `data-aqmb-current` | The current tab, or the menu tab when no tab is current |
467
557
 
468
558
  ## Advanced Usage
469
559
 
@@ -514,6 +604,7 @@ The main menu bar component that includes:
514
604
  - Backdrop that closes the drawer when pressed
515
605
  - Scrolling that stays in the drawer, off the page behind the drawer and the backdrop
516
606
  - Focus management and current page highlighting
607
+ - A bottom tab bar (`tabBar`) in place of the switch on a layout of your choosing
517
608
 
518
609
  ### SessionInfo
519
610
 
@@ -553,11 +644,12 @@ Hand-written component classes use the `.aiquants-` prefix (the modifiers are th
553
644
 
554
645
  - `.aiquants-menu-bar` - Main container (with the theme class `light` or `dark`; `position: relative`, the box a switch anchored by `--aqmb-switch-position: absolute` measures its insets from)
555
646
  - `.aiquants-menu-switch` - Open/close switch (`.aiquants-menu-switch-bars`, `.aiquants-menu-switch-bar`, `.aiquants-menu-switch-caption`)
556
- - `.aiquants-menu-content` - Drawer (never taller than the viewport; it scrolls as a whole on a short one, and is a boundary of scroll chaining)
647
+ - `.aiquants-menu-content` - Drawer (as tall as the viewport, or down to the tab bar's top edge while the bar is drawn; a size container named `aqmb-menu-drawer` that scrolls as a whole when short; a boundary of scroll chaining)
557
648
  - `.aiquants-menu-list-region` - The list's region between the drawer's header and footer
558
- - `.aiquants-menu-list-scroller` - The list's scroll container (`data-aqmb-menu-list`) on a tall viewport, a boundary of scroll chaining
649
+ - `.aiquants-menu-list-scroller` - The list's scroll container (`data-aqmb-menu-list`) in a tall drawer, a boundary of scroll chaining
559
650
  - `.aiquants-menu-list-item` - Menu item and group summary
560
651
  - `.aiquants-session-card` - Session information card
652
+ - `.aiquants-tab-bar` - Bottom tab bar (`.aiquants-tab-bar-tab` for each tab, `.aiquants-tab-bar-menu` for the menu tab, `.aiquants-tab-bar-icon`, `.aiquants-tab-bar-label`)
561
653
 
562
654
  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.
563
655
 
@@ -569,7 +661,7 @@ These classes are a styling contract, not DOM hooks — use the `data-aqmb-*` at
569
661
  - Firefox 128+
570
662
  - Safari 16.4+
571
663
 
572
- 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.
664
+ 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. The tab bar's rules rely on `:has()` and the short drawer's layout on a container size query, both within these versions.
573
665
 
574
666
  Every one of these versions stops a scroll at the ends of a list or a drawer that scrolls. Keeping the wheel off the page where nothing under the pointer can scroll (a list that fits, the header or the footer, the backdrop) needs Chrome/Edge 144+ or Firefox 150+; Safari and older versions scroll the page there.
575
667
 
package/dist/MenuBar.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { IMenu, MenuNavigationPayload } from "./contents/MenuList";
2
+ import { MenuBarTabBar } from "./contents/TabBar";
2
3
  import { Theme } from "./types";
3
4
  export interface IMenuBarProps {
4
5
  menuList: IMenu[];
@@ -16,5 +17,7 @@ export interface IMenuBarProps {
16
17
  initialPinnedSubmenuIds?: number[];
17
18
  onSubmenuPinChange?(pinnedIds: number[]): void;
18
19
  closeOnEscape?: boolean | "auto";
20
+ pinnable?: boolean;
21
+ tabBar?: MenuBarTabBar;
19
22
  }
20
23
  export declare const MenuBar: (props: IMenuBarProps) => React.JSX.Element | null;
@@ -8,10 +8,12 @@ interface IMenuContentProps {
8
8
  onClickSignout(): void;
9
9
  theme: Theme;
10
10
  toggleTheme: () => void;
11
+ canPin: boolean;
11
12
  isPinned: boolean;
12
13
  onTogglePin(): void;
13
14
  pinnedSubmenuIds: number[];
14
15
  onToggleSubmenuPin(id: number): void;
16
+ id?: string;
15
17
  ref: React.Ref<HTMLDivElement>;
16
18
  }
17
19
  export declare const MenuContent: (props: IMenuContentProps) => React.JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { ReactNode } from "react";
2
+ import { IMenu, MenuNavigationPayload } from "./MenuList";
3
+ import { MenuEntry } from "./menuEntries";
4
+ export interface MenuBarTab {
5
+ href: string;
6
+ label: string;
7
+ icon: ReactNode;
8
+ }
9
+ export interface MenuBarTabBar {
10
+ media: string;
11
+ tabs: readonly MenuBarTab[];
12
+ nonce?: string;
13
+ }
14
+ export declare const TAB_BAR_MAX_TABS = 4;
15
+ export type ResolvedTab = {
16
+ tab: MenuBarTab;
17
+ entry: MenuEntry;
18
+ };
19
+ export declare const resolveTabBarTabs: (menuList: IMenu[], tabs: readonly MenuBarTab[]) => ResolvedTab[];
20
+ export declare const tabBarMediaRules: (media: string) => string;
21
+ type TabBarProps = {
22
+ tabs: readonly ResolvedTab[];
23
+ menuList: IMenu[];
24
+ menuOpen: boolean;
25
+ drawerId: string;
26
+ onMenuOpen(): void;
27
+ onMenuClose(): void;
28
+ onTabSelected(payload: MenuNavigationPayload): void;
29
+ menuTabRef: React.Ref<HTMLButtonElement>;
30
+ };
31
+ export declare const TabBar: ({ tabs, menuList, menuOpen, drawerId, onMenuOpen, onMenuClose, onTabSelected, menuTabRef }: TabBarProps) => React.JSX.Element;
32
+ export {};
@@ -0,0 +1,18 @@
1
+ import { IMenu, ISubmenu, MenuNavigationPayload } from "./MenuList";
2
+ export type ActiveMenuEntry = {
3
+ menuId: number;
4
+ subid: number | null;
5
+ };
6
+ export type MenuEntry = {
7
+ menu: IMenu;
8
+ submenu: ISubmenu | null;
9
+ href: string;
10
+ };
11
+ export declare const SEPARATOR_HREF = "separator";
12
+ export declare const isDocumentHref: (href: string) => boolean;
13
+ export declare const normalizeHref: (href: string | undefined) => string;
14
+ export declare const findActiveMenuEntry: (menuList: IMenu[], pathname: string) => ActiveMenuEntry | null;
15
+ export declare const findMenuEntryByHref: (menuList: IMenu[], href: string) => MenuEntry | null;
16
+ export declare const isActiveEntry: (active: ActiveMenuEntry | null, menuId: number, subid: number | null) => boolean;
17
+ export declare const buildMenuPayload: (menu: IMenu, submenu?: ISubmenu) => MenuNavigationPayload;
18
+ export declare const useCurrentPathname: () => string;
@@ -0,0 +1,6 @@
1
+ import { Dispatch, SetStateAction } from "react";
2
+ export declare const useHydrated: () => boolean;
3
+ export declare const useHydrationSafeState: <T>(readBrowserValue: () => T, serverValue: T) => [
4
+ T,
5
+ Dispatch<SetStateAction<T>>
6
+ ];
@@ -0,0 +1 @@
1
+ export declare const useMediaQueryMatches: (media: string | undefined) => boolean;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { LinkProps as MenuBarLinkProps, LinkProps, SessionInfoProps as MenuBarSessionInfoProps, SessionInfoProps } from "./components/SessionInfo";
2
2
  export { DefaultLink, SessionInfo } from "./components/SessionInfo";
3
3
  export type { IMenu as IMenuBarMenu, IMenu, ISubmenu as IMenuBarSubmenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from "./contents/MenuList";
4
+ export type { MenuBarTab, MenuBarTabBar } from "./contents/TabBar";
4
5
  export type { SubscribeMenuPinOptions } from "./events/pinEvent";
5
6
  export { dispatchMenuPinChange, getInitialPinState, MENU_PIN_CHANGE_EVENT, subscribeMenuPinChange, } from "./events/pinEvent";
6
7
  export type { UseMenuPinStateOptions } from "./hooks/useMenuPinState";