ab-nextjs-core 0.1.4 โ†’ 0.1.6

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
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ### 0.1.6 (2026-10-16)
6
+
7
+ * ๐Ÿงญ **Docs-shell panes (G17):** `AbAppLayout` (client + server) gets `variant="panes"` and `railCollapsed`. Four breakpoints: mobile (bottom bar, aside as a full-screen sheet), tablet 768โ€“1023 (280px sidebar or 72px rail, overlay aside on the right, hidden by default), laptop 1024โ€“1439 (joined panes, hairline dividers), desktop โ‰ฅ 1440 (gapped panes with radii). Uses the theme 0.3.4 tokens with fallbacks. The default variant is unchanged
8
+ * ๐Ÿงท **Rail:** 200ms width transition, instant under reduced motion; stays expanded with JS off (`@media (scripting: none)`)
9
+ * ๐Ÿ“‘ **Aside toggles (useAbPage):** `toggleAside`, `toggleAsideOnMobile`, `asideId` (the aside's `id`), and **`useAbAsideToggle()`**, which picks the sheet or the pane for the viewport and returns `aria-expanded` / `aria-controls` / `onClick`. `Escape` closes the sheet
10
+ * ๐ŸŽž๏ธ **Aside slide:** `slideFromRight` (ab-nextjs-animations) at **240ms** (`asideDuration` default 240, was 300); under reduced motion no slide and instant open/close (`getAbAsideDuration`)
11
+ * ๐Ÿ“ **Layout rule (D35, G50โ€“G52, G54):** the page never scrolls. In panes the app is a `100dvh` grid (`auto minmax(0,1fr) auto`, `overflow:hidden`) and `<html>`/`<body>` are locked while it is mounted (`:has()` plus a `data-ab-layout-lock` fallback, counted by `acquireAbLayoutLock()` so unmounting one of two layouts keeps it). Main, aside and sidebar each scroll on their own: `min-height:0`, `overscroll-behavior: contain`, `scrollbar-gutter: stable`, safe-area padding. The 64px `--ab-app-bar-height` offset and the sticky columns are gone
12
+ * ๐Ÿซฅ **Empty chrome is 0px (G50):** null header/footer slots are not rendered, and `:empty` / `:has()` collapse the rest; strings are wrapped (`toAbSlot`) so text-only slots never collapse
13
+ * ๐Ÿ” **New slots (G52):** `AbMainLayout` `mainHeader` / `mainBar`, `AbAsideLayout` `asideHeader` / `asideBar` (`data-ab-part="main-header"` โ€ฆ). `header` / `footer` / `chrome` stay as deprecated aliases
14
+ * ๐Ÿ“œ **Scrollers on useAbPage (G55):** `mainScrollElement` / `asideScrollElement` (content wrappers are `data-ab-part="main-content"` / `"aside-content"`), for hooks 0.1.7 `useAbScrollSpy({ root })`. The server `AbMainLayout` / `AbAsideLayout` set them too, through a tiny client island (`AbScrollRegistrar`, one hidden `<span>` as the scroller's first child) (Joseph)
15
+ * ๐Ÿงฑ **No nested headers:** in panes (or when the slot child is already a `<header>`) the header wrapper is a `<div>`; `overlays` drops the legacy layers (off by default in panes); the pane CSS also targets AbSidebar's `data-ab-part="rail"`
16
+ * ๐Ÿ†” **AbMainLayout / AbAsideLayout:** `id` prop, and `chrome={false}` drops their empty header/footer wrappers and backdrop/menus/dialogs/toasts/progress/canvas (default `true`, unchanged). Server `AbAsideLayout` sets `id` (default `ab-aside`)
17
+ * ๐ŸŒซ๏ธ **Sheet (James):** tablet backdrop (click closes), focus moves into the sheet and back to the opener, `Escape` skips events an inner menu already `preventDefault`ed, and one open/close timer so quick toggles never race
18
+ * ๐Ÿ”ง `typecheck` script; ๐Ÿ“ house `@authors` email in the code file headers
19
+ * Tests: per-breakpoint CSS, rail + reduced motion + JS off, aside 240ms / reduced motion, toggles and aria. Checked in WebKit at 390 / 768 / 1280 / 1600px
20
+
21
+ ### 0.1.5 (2026-10-05)
22
+
23
+ * **Landmarks:** header / footer wrappers set `data-ab-part="header"` / `data-ab-part="footer"`
24
+
5
25
  ### 0.1.4 (2026-10-05)
6
26
 
7
27
  * **Semantics:** layout wrappers use landmark tags โ€” `<header>` / `<footer>` (was `<div>`); `<main>` and `<aside>` unchanged; sidebar / bottom bar stay the consumer's `<nav>` (`AbSidebar` / `AbNavbar` in components)
package/README.md CHANGED
@@ -56,13 +56,59 @@ Optional: wrap client `AbAsideLayout` with `AbPageProvider` / `useAbPage` for as
56
56
  |:--------------|:---------|:---------------|
57
57
  | `AbMainLayout` | `<main>` | `main` |
58
58
  | `AbAsideLayout` | `<aside>` | `aside` |
59
- | Header wrapper | `<header>` | โ€” |
60
- | Footer wrapper | `<footer>` | โ€” |
59
+ | Header wrapper | `<header>` | `header` |
60
+ | Footer wrapper | `<footer>` | `footer` |
61
+ | Main slots (0.1.6) | `<header>` / `<footer>` | `main-header` / `main-bar`, scroller `main-content` |
62
+ | Aside slots (0.1.6) | `<header>` / `<footer>` | `aside-header` / `aside-bar`, scroller `aside-content` |
61
63
  | Sidebar / bottom bar (via components) | `<nav>` (`AbSidebar` / `AbNavbar`) | `sidebar` (sidebar) |
62
64
 
63
65
  The client aside opens with the `.slideFromRight` class from `ab-nextjs-animations` (import `ab-nextjs-animations/slide-from-right/styles.css`).
64
66
 
65
67
 
68
+ ### Docs shell panes (0.1.6)
69
+
70
+ ```tsx
71
+ 'use client'
72
+ import AbAppLayout from 'ab-nextjs-core/ab-app-layout'
73
+ import AbMainLayout from 'ab-nextjs-core/ab-main-layout'
74
+ import AbAsideLayout from 'ab-nextjs-core/ab-aside-layout'
75
+ import { AbPageProvider, useAbAsideToggle } from 'ab-nextjs-core/ab-page-provider'
76
+
77
+ <AbPageProvider>
78
+ <AbAppLayout
79
+ orientation="vertical"
80
+ variant="panes" // ๐Ÿงญ 4 breakpoints
81
+ railCollapsed={rail} // ๐Ÿงท icons-only sidebar (tablet+)
82
+ header={<AppBar />}
83
+ sideBar={<nav data-ab-part="sidebar" aria-label="Documentation">โ€ฆ</nav>}
84
+ content={<><AbMainLayout โ€ฆ /><AbAsideLayout โ€ฆ /></>}
85
+ navBar={<nav data-ab-part="bottomBar" aria-label="Primary">โ€ฆ</nav>}
86
+ />
87
+ </AbPageProvider>
88
+
89
+ // ๐Ÿ“‘ one button for the TOC sheet (<1024px) and the ยป/ยซ pane toggle (โ‰ฅ1024px)
90
+ const { props, expanded } = useAbAsideToggle() // aria-expanded + aria-controls + onClick
91
+ <button {...props}>{expanded ? 'Hide page outline' : 'Show page outline'}</button>
92
+ ```
93
+
94
+ | Width | Sidebar | Main | Aside | Bottom bar |
95
+ |---|---|---|---|---|
96
+ | < 768 | hidden | full | full-screen sheet (`isAsideOpenOnMobile`) | shown (safe-area padding) |
97
+ | 768โ€“1023 | 280px, or the 72px rail | rest | overlay on the right, hidden by default | hidden |
98
+ | 1024โ€“1439 | joined, hairline divider | | in the flow, `isAsideOpen` | hidden |
99
+ | โ‰ฅ 1440 | gapped panes (`--ab-pane-gap`) with radii (`--ab-pane-radius`) | | | hidden |
100
+
101
+ * ๐Ÿ“ **The page never scrolls.** Panes are a `100dvh` box and `<html>`/`<body>` are locked while mounted. Main, aside and sidebar scroll on their own (`min-height:0`, `overscroll-behavior: contain`, `scrollbar-gutter: stable`, safe-area padding). Hand `useAbPage().mainScrollElement` to `useAbScrollSpy({ root })` (hooks 0.1.7). The client and the server layouts both set it (the server ones through a hidden client `AbScrollRegistrar`); wrap the app in `AbPageProvider`.
102
+ * ๐Ÿ“ฑ **Viewport meta for the app:** add `viewport-fit=cover` so `env(safe-area-inset-*)` is non-zero on notched phones (the panes pad the bars with it), and `interactive-widget=resizes-content` so the on-screen keyboard shrinks the `100dvh` box instead of sliding the locked page. In Next.js:
103
+
104
+ ```ts
105
+ export const viewport = { width: 'device-width', initialScale: 1, viewportFit: 'cover', interactiveWidget: 'resizes-content' }
106
+ ```
107
+ * ๐Ÿซฅ Empty header / footer slots render nothing and measure 0px. Use `mainHeader` / `mainBar` and `asideHeader` / `asideBar`; `header` / `footer` / `chrome` are deprecated aliases.
108
+ * The rail animates its width over 200ms and snaps under reduced motion. Without JS the sidebar stays expanded (`@media (scripting: none)`).
109
+ * The aside uses the `slideFromRight` keyframe from **ab-nextjs-animations** (load its `slide-from-right/styles.css`) at 240ms. Under reduced motion there's no slide and the open/close timers are 0. `Escape` closes the sheet.
110
+ * Tokens come from **ab-nextjs-theme โ‰ฅ 0.3.4** (`--ab-pane-gap`, `--ab-pane-radius`, `--ab-divider-hairline`), with fallbacks.
111
+
66
112
  ## Getting Started
67
113
 
68
114
  ### Installation
@@ -24,7 +24,7 @@
24
24
  * SOFTWARE.
25
25
  *
26
26
  * @project: ab-nextjs-core
27
- * @authors: Abraham Ukachi <abraham.ukachi@laplateforme.io>
27
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
28
28
  */
29
29
 
30
30
  /*
@@ -35,10 +35,32 @@
35
35
 
36
36
  'use client';
37
37
 
38
- import type { ReactElement, ReactNode } from 'react';
38
+ import { isValidElement, useEffect, type ReactElement, type ReactNode } from 'react';
39
39
  import clsx from 'clsx';
40
40
  import AbLinearProgress from '../ab-linear-progress';
41
41
  import styles from './styles.module.css';
42
+ // ๐Ÿซฅ G50: empty-slot helper
43
+ import { toAbSlot } from '../ab-page-provider/slot';
44
+
45
+ // ๐Ÿ”ข how many panes layouts are mounted (module scope: one document per bundle)
46
+ let abLayoutLocks = 0;
47
+
48
+ /**
49
+ * ๐Ÿ”’ acquireAbLayoutLock โ€” sets `data-ab-layout-lock` on `<html>` and returns the release function.
50
+ * Counted: the attribute only goes away when the LAST mounted panes layout releases (James nit).
51
+ * Releasing twice is a no-op.
52
+ */
53
+ export function acquireAbLayoutLock(doc: Document = document): () => void {
54
+ abLayoutLocks += 1; // โž•
55
+ doc.documentElement.setAttribute('data-ab-layout-lock', ''); // ๐Ÿ”’
56
+ let released = false; // ๐Ÿ›ก๏ธ StrictMode double cleanups
57
+ return () => {
58
+ if (released) return;
59
+ released = true;
60
+ abLayoutLocks = Math.max(0, abLayoutLocks - 1); // โž–
61
+ if (abLayoutLocks === 0) doc.documentElement.removeAttribute('data-ab-layout-lock'); // ๐Ÿ”“ last one out
62
+ };
63
+ }
42
64
 
43
65
  export interface AbAppLayoutProps {
44
66
  orientation: 'vertical' | 'horizontal';
@@ -52,6 +74,13 @@ export interface AbAppLayoutProps {
52
74
  footer?: ReactNode;
53
75
  children?: ReactNode;
54
76
  menus?: ReactNode;
77
+ /** ๐Ÿงญ 0.1.6 (G17): `'panes'` = the docs shell (4 breakpoints, rail, overlay aside). Default keeps the old layout. */
78
+ variant?: 'default' | 'panes';
79
+ /** ๐Ÿงท 0.1.6: collapse the sidebar to the icon rail (tablet and up, `variant="panes"`). */
80
+ railCollapsed?: boolean;
81
+ /** ๐Ÿงฐ 0.1.6: render the legacy #backdrop / #menus / #dialogs / #toasts / progress / canvas layers.
82
+ * Default: on for `variant="default"`, off for `"panes"` (the shell brings its own menus and dialogs). */
83
+ overlays?: boolean;
55
84
  }
56
85
 
57
86
  /** Client markup mirror of server AbAppLayout (slots only). */
@@ -67,39 +96,69 @@ const AbAppLayout = ({
67
96
  footer,
68
97
  children,
69
98
  menus,
99
+ variant = 'default', // ๐Ÿงญ
100
+ railCollapsed = false, // ๐Ÿงท
101
+ overlays, // ๐Ÿงฐ
70
102
  }: AbAppLayoutProps): ReactElement => {
103
+ // ๐Ÿงฑ panes: the header slot is a plain grid row (0px when empty). Its child (e.g. AbAppBar) brings its own
104
+ // <header>, so we never nest one header inside another. Same when the slot child IS a <header>.
105
+ const HeaderTag = variant === 'panes' || (isValidElement(header) && header.type === 'header') ? 'div' : 'header';
106
+ // ๐Ÿงฐ legacy overlay layers (off in panes unless asked for)
107
+ const showOverlays = overlays ?? variant !== 'panes';
108
+ // ๐Ÿซฅ G50 (D37): panes never render an empty header / footer row (0px); the CSS :empty / :has() catch the rest
109
+ const top = toAbSlot(header);
110
+ const showHeader = variant !== 'panes' || top != null;
111
+ const showFooter = variant !== 'panes' || toAbSlot(footer) != null;
112
+ // ๐Ÿ”’ G54: lock the document while a panes layout is mounted (CSS :has() does it too; this covers older engines)
113
+ useEffect(() => {
114
+ if (variant !== 'panes' || typeof document === 'undefined') return;
115
+ return acquireAbLayoutLock(); // ๐Ÿ”’ counted, so unmounting one of two layouts keeps the lock (James)
116
+ }, [variant]);
71
117
  return (
72
118
  <div
73
119
  className={clsx(['AbAppLayout', styles.abAppLayout, className])}
74
120
  hidden={hidden}
75
121
  data-page-name={pageName}
76
122
  data-orientation={orientation ?? ''}
123
+ data-variant={variant} // ๐Ÿงญ CSS hook for the panes
124
+ data-rail={railCollapsed ? 'true' : 'false'} // ๐Ÿงท
77
125
  >
78
- <header className={clsx(['HeaderWrapper', styles.abAppLayout__headerWrapper])}>
79
- {header}
80
- </header>
126
+ {showHeader && (
127
+ <HeaderTag className={clsx(['HeaderWrapper', styles.abAppLayout__headerWrapper])} data-ab-part="header">
128
+ {variant === 'panes' ? top : header}
129
+ </HeaderTag>
130
+ )}
81
131
  <div className={clsx(['ContentWrapper', styles.abAppLayout__contentWrapper])}>
82
132
  {sideBar}
83
133
  {content ?? children}
84
134
  {navBar}
85
135
  </div>
86
- <footer className={clsx(['FooterWrapper', styles.abAppLayout__footerWrapper])}>
87
- {footer}
88
- </footer>
89
- <div id="backdrop" className={clsx(['Backdrop', styles.abAppLayout__backdrop])} hidden />
90
- <div id="menus" className={clsx(['Menus', styles.abAppLayout__menus])} hidden>
91
- {menus}
92
- </div>
93
- <div id="dialogs" className={clsx(['Dialogs', styles.abAppLayout__dialogs])} hidden />
94
- <div id="toasts" className={clsx(['Toasts', styles.abAppLayout__toasts])} hidden />
95
- <AbLinearProgress
96
- className="absolute top-0 left-0 right-0 z-50 !h-0.5 lg:!h-1"
97
- color="secondary"
98
- hidden={true}
99
- isIndeterminate={true}
100
- progress={25}
101
- />
102
- <canvas className={clsx(['Canvas', styles.abAppLayout__canvas])} />
136
+ {showFooter && (
137
+ <footer className={clsx(['FooterWrapper', styles.abAppLayout__footerWrapper])} data-ab-part="footer">
138
+ {footer}
139
+ </footer>
140
+ )}
141
+ {/* ๐Ÿงฐ legacy layers (ids kept for old apps) */}
142
+ {showOverlays && (
143
+ <>
144
+ <div id="backdrop" className={clsx(['Backdrop', styles.abAppLayout__backdrop])} hidden />
145
+ <div id="menus" className={clsx(['Menus', styles.abAppLayout__menus])} hidden>
146
+ {menus}
147
+ </div>
148
+ <div id="dialogs" className={clsx(['Dialogs', styles.abAppLayout__dialogs])} hidden />
149
+ <div id="toasts" className={clsx(['Toasts', styles.abAppLayout__toasts])} hidden />
150
+ <AbLinearProgress
151
+ className="absolute top-0 left-0 right-0 z-50 !h-0.5 lg:!h-1"
152
+ color="secondary"
153
+ hidden={true}
154
+ isIndeterminate={true}
155
+ progress={25}
156
+ />
157
+ <canvas className={clsx(['Canvas', styles.abAppLayout__canvas])} />
158
+ </>
159
+ )}
160
+ {/* ๐Ÿ“‹ panes: still render menus when the app passes some */}
161
+ {!showOverlays && menus != null && <div className={clsx(['Menus', styles.abAppLayout__menus])}>{menus}</div>}
103
162
  </div>
104
163
  );
105
164
  };
@@ -27,7 +27,7 @@
27
27
  * @name: AB App Layout - Component Stylesheet (plain CSS, Tailwind-independent)
28
28
  * @file: ab-app-layout/styles.module.css
29
29
  * @type: CSS Module
30
- * @authors: Abraham Ukachi <abraham.ukachi@laplateforme.io>
30
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
31
31
  */
32
32
 
33
33
  /*
@@ -143,3 +143,220 @@
143
143
  from { opacity: 0; }
144
144
  to { opacity: 1; }
145
145
  }
146
+
147
+ /* ================================================================== */
148
+ /* ๐Ÿงญ 0.1.6 (G17 + D34/D35 layout rule): docs-shell panes */
149
+ /* opt in with variant="panes". Children of the content wrapper */
150
+ /* are matched by data-ab-part: */
151
+ /* sidebar | rail (nav) ยท main ยท aside ยท bottomBar (nav) */
152
+ /* ๐Ÿ“ฑ <768 ยท ๐Ÿ“Ÿ 768โ€“1023 ยท ๐Ÿ’ป 1024โ€“1439 joined ยท ๐Ÿ–ฅ๏ธ โ‰ฅ1440 gapped */
153
+ /* */
154
+ /* ๐Ÿ“ THE LAYOUT RULE (God, D34): */
155
+ /* ยท the app is ONE 100dvh box and the DOCUMENT NEVER SCROLLS */
156
+ /* ยท header / footer rows are 0px (not rendered / :empty) when empty*/
157
+ /* ยท sidebar, main content and aside content each scroll on their */
158
+ /* own (min-height:0 + overflow:auto + overscroll containment) */
159
+ /* ยท no hard-coded bar heights: rows size to their content */
160
+ /* Tokens: --ab-pane-gap / --ab-pane-radius / --ab-divider-hairline */
161
+ /* (ab-nextjs-theme โ‰ฅ 0.3.4), with safe fallbacks */
162
+ /* ================================================================== */
163
+
164
+ /* ๐Ÿ”’ G54: lock the document while a panes layout is on the page (CSS-only via :has, so the server twin
165
+ * works without JS; the client twin also sets html[data-ab-layout-lock] for browsers without :has) */
166
+ :global(html:has(.AbAppLayout[data-variant="panes"])),
167
+ :global(html:has(.AbAppLayout[data-variant="panes"])) :global(body),
168
+ :global(html[data-ab-layout-lock]),
169
+ :global(html[data-ab-layout-lock]) :global(body) {
170
+ height: 100%; /* ๐Ÿ“ */
171
+ overflow: hidden; /* ๐Ÿšซ the page itself never scrolls */
172
+ overscroll-behavior: none; /* ๐Ÿšซ no iOS rubber band pulling the whole app */
173
+ }
174
+
175
+ .abAppLayout[data-variant="panes"] {
176
+ --ab-sidebar-width: 280px; /* ๐Ÿ—‚๏ธ expanded sidebar */
177
+ --ab-rail-width: 72px; /* ๐Ÿงท collapsed rail (icons only) */
178
+ --ab-aside-width: min(40vw, 360px); /* ๐Ÿ“‘ "On this page" */
179
+ --ab-rail-duration: 200ms; /* ๐ŸŽž๏ธ rail collapse (brief ยง4) */
180
+ display: grid; /* ๐Ÿงฑ rows: header ยท panes ยท footer */
181
+ grid-template-rows: auto minmax(0, 1fr) auto; /* ๐Ÿ“ empty header/footer rows are 0px */
182
+ grid-template-columns: minmax(0, 1fr); /* ๐Ÿšซ no horizontal overflow */
183
+ width: 100%; /* โ†”๏ธ */
184
+ height: 100vh; /* ๐Ÿ•ฐ๏ธ fallback */
185
+ height: 100dvh; /* ๐Ÿ“ฑ the dynamic viewport (mobile toolbars) */
186
+ min-height: 0; /* ๐Ÿšซ */
187
+ overflow: hidden; /* ๐Ÿ”’ the app box never scrolls */
188
+ overscroll-behavior: contain; /* ๐Ÿšซ scroll chaining */
189
+ }
190
+
191
+ /* ๐Ÿ”๐Ÿฆถ app header / footer: plain grid rows in the flow, 0px + invisible when empty (G50) */
192
+ .abAppLayout[data-variant="panes"] > .abAppLayout__headerWrapper,
193
+ .abAppLayout[data-variant="panes"] > .abAppLayout__footerWrapper {
194
+ position: relative; /* ๐Ÿ“Œ in the flow (no overlay) */
195
+ inset: auto; /* ๐Ÿงน the legacy absolute overlay */
196
+ z-index: 25; /* โฌ†๏ธ above the panes, below the sheet (30) */
197
+ pointer-events: auto; /* ๐Ÿ–ฑ๏ธ the legacy wrapper disabled them */
198
+ min-width: 0; /* ๐Ÿšซ overflow */
199
+ padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px); /* ๐Ÿ“ฑ notch */
200
+ }
201
+ .abAppLayout[data-variant="panes"] > .abAppLayout__headerWrapper { grid-row: 1; padding-top: env(safe-area-inset-top, 0px); } /* ๐Ÿ” row 1 ยท ๐Ÿ“ฑ */
202
+ /* ๐Ÿ“ WebKit e2e (2026-10-20): pin the panes to the 1fr row. Auto-placement moved them into row 1 (auto) when
203
+ the header was absent or collapsed, and the panes measured 0px tall */
204
+ .abAppLayout[data-variant="panes"] > .abAppLayout__contentWrapper { grid-row: 2; } /* ๐Ÿงฑ the minmax(0,1fr) row */
205
+ .abAppLayout[data-variant="panes"] > .abAppLayout__footerWrapper { grid-row: 3; padding-bottom: env(safe-area-inset-bottom, 0px); } /* ๐Ÿ“ฑ */
206
+ /* ๐Ÿซฅ D37: not rendered when the prop is null; :empty / :has() collapse what still reaches the DOM */
207
+ .abAppLayout[data-variant="panes"] > .abAppLayout__headerWrapper:empty,
208
+ .abAppLayout[data-variant="panes"] > .abAppLayout__footerWrapper:empty,
209
+ .abAppLayout[data-variant="panes"] > .abAppLayout__headerWrapper:not(:has(> *)),
210
+ .abAppLayout[data-variant="panes"] > .abAppLayout__footerWrapper:not(:has(> *)) {
211
+ display: none; /* ๐Ÿ“ 0px */
212
+ }
213
+ /* ๐Ÿ“‹ menus the app passes: no full-screen click shield */
214
+ .abAppLayout[data-variant="panes"] > .abAppLayout__menus {
215
+ display: contents; /* ๐Ÿซฅ */
216
+ }
217
+
218
+ /* ๐Ÿงฑ the panes row: a bounded box (the positioning context for sheets and future drawers) */
219
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper {
220
+ position: relative; /* ๐Ÿ“ sheets / drawers are scoped to it */
221
+ flex-direction: column; /* ๐Ÿ“ฑ stack */
222
+ height: 100%; /* ๐Ÿ“ the 1fr row */
223
+ min-height: 0; /* ๐Ÿšซ lets the children shrink and scroll */
224
+ min-width: 0; /* ๐Ÿšซ overflow */
225
+ overflow: hidden; /* ๐Ÿ”’ */
226
+ justify-content: flex-start; /* โฌ†๏ธ */
227
+ }
228
+
229
+ /* ๐Ÿ“œ every pane: bounded, scrolls on its own, never chains to the page (G51 + G54) */
230
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"], [data-ab-part="main"], [data-ab-part="aside"]) {
231
+ min-height: 0; /* ๐Ÿšซ */
232
+ max-height: none; /* ๐Ÿงน legacy 100vh caps */
233
+ box-sizing: border-box; /* ๐Ÿ“ฆ */
234
+ overscroll-behavior: contain; /* ๐Ÿšซ scroll chaining */
235
+ }
236
+
237
+ /* ๐Ÿ“ฑ mobile: one column, no sidebar, bottom bar visible, aside only as a full-pane sheet */
238
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]) {
239
+ display: none; /* ๐Ÿ™ˆ the bottom bar takes over */
240
+ }
241
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="main"] {
242
+ flex: 1 1 0; /* โ†”๏ธโ†•๏ธ the rest of the box */
243
+ min-width: 0; /* ๐Ÿšซ overflow */
244
+ height: auto; /* ๐Ÿ“ the flex item size */
245
+ overflow: hidden; /* ๐Ÿ”’ its content wrapper is the scroller */
246
+ }
247
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"] {
248
+ position: absolute; /* ๐Ÿ“„ sheet over main, INSIDE the app box */
249
+ inset: 0; /* ๐Ÿงฑ 100% (TOC button) */
250
+ width: 100%;
251
+ height: auto; /* ๐Ÿ“ from inset */
252
+ z-index: 30; /* โฌ†๏ธ above main + header, below menus/dialogs (50) */
253
+ overflow: hidden; /* ๐Ÿ”’ its content wrapper scrolls */
254
+ }
255
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"]:not([data-open-on-mobile="true"]) {
256
+ display: none; /* ๐Ÿ™ˆ closed by default on mobile */
257
+ }
258
+ /* ๐ŸŒซ๏ธ sheet backdrop (rendered by AbAsideLayout): only while the sheet is open */
259
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside-backdrop"] {
260
+ display: none; /* ๐Ÿ™ˆ */
261
+ }
262
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="bottomBar"] {
263
+ flex: none; /* ๐Ÿ“ its own height: a row at the bottom of the box (no sticky needed) */
264
+ padding-bottom: env(safe-area-inset-bottom, 0px); /* ๐Ÿ“ฑ home indicator */
265
+ }
266
+
267
+ /* ๐Ÿ“Ÿ tablet: sidebar (or rail) + main; the aside overlays on the right over a backdrop; no bottom bar */
268
+ @media (min-width: 768px) {
269
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper {
270
+ flex-direction: row; /* โžก๏ธ */
271
+ align-items: stretch; /* ๐Ÿ“ every pane is full height */
272
+ }
273
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]) {
274
+ display: flex; /* ๐Ÿ‘€ */
275
+ flex: none;
276
+ width: var(--ab-sidebar-width); /* ๐Ÿ—‚๏ธ */
277
+ max-width: none; /* ๐Ÿงน the component's own cap */
278
+ height: auto; /* ๐Ÿ“ stretched by the row */
279
+ overflow: hidden auto; /* ๐Ÿ“œ D42: the sidebar is the third scroller */
280
+ scrollbar-gutter: stable; /* ๐Ÿ“ no jump when it starts scrolling */
281
+ transition: width var(--ab-rail-duration) ease; /* ๐ŸŽž๏ธ rail */
282
+ }
283
+ .abAppLayout[data-variant="panes"][data-rail="true"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]),
284
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="rail"] {
285
+ width: var(--ab-rail-width); /* ๐Ÿงท rail (layout flag or AbSidebar's own rail markup) */
286
+ }
287
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="bottomBar"] {
288
+ display: none; /* ๐Ÿ™ˆ sidebar replaces it */
289
+ }
290
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"] {
291
+ inset: 0 0 0 auto; /* โžก๏ธ pinned right, inside the panes box */
292
+ width: var(--ab-aside-width); /* ๐Ÿ“‘ ~30โ€“40% */
293
+ border-left: var(--ab-divider-hairline, 1px solid rgb(0 0 0 / 0.12)); /* โž– */
294
+ background: var(--ab-color-surface, Canvas); /* ๐ŸŽจ opaque over the page */
295
+ }
296
+ /* ๐ŸŒซ๏ธ tablet overlay backdrop (James review): dims the panes, click closes the sheet */
297
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside-backdrop"][data-open="true"] {
298
+ display: block; /* ๐Ÿ‘€ */
299
+ position: absolute; /* ๐Ÿ“„ inside the panes box */
300
+ inset: 0; /* ๐Ÿงฑ */
301
+ z-index: 29; /* โฌ‡๏ธ just under the sheet */
302
+ background: rgb(0 0 0 / 0.32); /* ๐ŸŒซ๏ธ scrim */
303
+ }
304
+ }
305
+
306
+ /* ๐Ÿ’ป laptop: three joined panes with hairline dividers, aside in the flow */
307
+ @media (min-width: 1024px) {
308
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]) {
309
+ border-right: var(--ab-divider-hairline, 1px solid rgb(0 0 0 / 0.12)); /* โž– joined */
310
+ }
311
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"] {
312
+ position: relative; /* ๐Ÿ“Œ back in the flow, a full-height column */
313
+ inset: auto; /* ๐Ÿงน the tablet overlay */
314
+ height: auto; /* ๐Ÿ“ stretched by the row */
315
+ flex: none;
316
+ z-index: auto;
317
+ background: none; /* ๐ŸŽจ */
318
+ }
319
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"][data-opened="true"] {
320
+ display: flex; /* ๐Ÿ‘€ laptop+ follows data-opened (isAsideOpen), not the small-screen flag (same specificity as the :not() rule, later wins) */
321
+ }
322
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"]:not([data-opened="true"]) {
323
+ display: none; /* ๐Ÿ™ˆ closed with the ยป/ยซ toggle */
324
+ }
325
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside-backdrop"] {
326
+ display: none !important; /* ๐Ÿ™ˆ no sheet on laptop+ */
327
+ }
328
+ }
329
+
330
+ /* ๐Ÿ–ฅ๏ธ desktop: gapped panes with radii, no dividers */
331
+ @media (min-width: 1440px) {
332
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper {
333
+ gap: var(--ab-pane-gap, 12px); /* ๐Ÿงฑ */
334
+ padding: var(--ab-pane-gap, 12px); /* ๐Ÿ–ผ๏ธ outer gap */
335
+ box-sizing: border-box;
336
+ }
337
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]),
338
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="main"],
339
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > [data-ab-part="aside"] {
340
+ border: 0; /* ๐Ÿšซ dividers */
341
+ border-radius: var(--ab-pane-radius, 16px); /* ๐Ÿ”ฒ */
342
+ overflow: hidden; /* โœ‚๏ธ radius */
343
+ margin: 0; /* ๐Ÿงน legacy 2xl margins */
344
+ }
345
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]) {
346
+ overflow: hidden auto; /* ๐Ÿ“œ the sidebar still scrolls (radius kept) */
347
+ }
348
+ }
349
+
350
+ /* โ™ฟ reduced motion: the rail snaps instead of animating */
351
+ @media (prefers-reduced-motion: reduce) {
352
+ .abAppLayout[data-variant="panes"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]) {
353
+ transition: none;
354
+ }
355
+ }
356
+
357
+ /* ๐Ÿšซ JS off: nothing can toggle the rail, so the sidebar stays expanded (gap-review, Joseph) */
358
+ @media (scripting: none) {
359
+ .abAppLayout[data-variant="panes"][data-rail="true"] .abAppLayout__contentWrapper > :is([data-ab-part="sidebar"], [data-ab-part="rail"]) {
360
+ width: var(--ab-sidebar-width);
361
+ }
362
+ }
@@ -24,7 +24,7 @@
24
24
  * SOFTWARE.
25
25
  *
26
26
  * @project: ab-nextjs-core
27
- * @authors: Abraham Ukachi <abraham.ukachi@laplateforme.io>
27
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
28
28
  */
29
29
 
30
30
  /*
@@ -41,18 +41,31 @@ import clsx from 'clsx';
41
41
  import { useAbPage } from '../ab-page-provider';
42
42
  import AbLinearProgress from '../ab-linear-progress';
43
43
  import styles from './styles.module.css';
44
+ // ๐Ÿซฅ G52: empty-slot helper (strings get a <span>, so the :has() collapse never hides text)
45
+ import { toAbSlot } from '../ab-page-provider/slot';
44
46
 
45
47
  export interface AbAsideLayoutProps {
46
48
  orientation: 'vertical' | 'horizontal';
47
49
  className?: string;
48
50
  hidden?: boolean;
49
51
  pageName?: string;
52
+ /** ๐Ÿ” 0.1.6 (G52): the aside pane's header slot (data-ab-part="aside-header"); 0px and not rendered when empty. */
53
+ asideHeader?: ReactNode;
54
+ /** ๐Ÿ”š 0.1.6 (G52): the aside pane's bottom bar slot (data-ab-part="aside-bar"); 0px and not rendered when empty. */
55
+ asideBar?: ReactNode;
56
+ /** @deprecated 0.1.6: use `asideHeader` (kept as an alias for one minor). */
50
57
  header?: ReactNode;
51
58
  content?: ReactNode;
59
+ /** @deprecated 0.1.6: use `asideBar` (kept as an alias for one minor). */
52
60
  footer?: ReactNode;
53
61
  children?: ReactNode;
54
62
  dividerHidden?: boolean;
55
63
  menus?: ReactNode;
64
+ /** ๐Ÿ†” 0.1.6: element id (e.g. the skip-link target), no inner wrapper needed. */
65
+ id?: string;
66
+ /** @deprecated 0.1.6: `false` drops the legacy backdrop / menus / dialogs / toasts / progress / canvas layers.
67
+ * Empty header / bar slots are never rendered any more, so this only gates those layers (default `true`). */
68
+ chrome?: boolean;
56
69
  }
57
70
 
58
71
  /**
@@ -64,12 +77,16 @@ const AbAsideLayout = ({
64
77
  className,
65
78
  hidden,
66
79
  pageName,
67
- header,
80
+ asideHeader,
81
+ asideBar,
82
+ header, // ๐Ÿ•ฐ๏ธ deprecated alias
68
83
  content,
69
- footer,
84
+ footer, // ๐Ÿ•ฐ๏ธ deprecated alias
70
85
  children,
71
86
  dividerHidden,
72
87
  menus,
88
+ id, // ๐Ÿ†”
89
+ chrome = true, // ๐Ÿงฐ
73
90
  }: AbAsideLayoutProps): ReactElement => {
74
91
  const {
75
92
  isAsideOpen,
@@ -80,6 +97,8 @@ const AbAsideLayout = ({
80
97
  isAsideOpening,
81
98
  isAsideClosing,
82
99
  isAsideLoading,
100
+ asideId,
101
+ setAsideOpenOnMobile,
83
102
  } = useAbPage();
84
103
 
85
104
  const asideRef = useRef<HTMLElement | null>(null);
@@ -129,15 +148,31 @@ const AbAsideLayout = ({
129
148
  asideDuration,
130
149
  ]);
131
150
 
151
+ // ๐Ÿ”๐Ÿ”š G52: the new slot names win, the 0.1.5 names still work
152
+ const top = toAbSlot(asideHeader ?? header);
153
+ const bar = toAbSlot(asideBar ?? footer);
154
+ // ๐Ÿ“œ G55: publish this pane's scroller (the content wrapper) so scroll spies / TOCs can use it as `root`
155
+ const { setAsideScrollElement } = useAbPage();
132
156
  return (
157
+ <>
158
+ {/* ๐ŸŒซ๏ธ 0.1.6 (James review): tablet/mobile sheet backdrop, a sibling so AbAppLayout's pane CSS shows it; click closes */}
159
+ <div
160
+ data-ab-part="aside-backdrop"
161
+ data-open={isAsideOpenOnMobile ? 'true' : 'false'}
162
+ aria-hidden="true"
163
+ onClick={() => setAsideOpenOnMobile(false)}
164
+ hidden={!isAsideOpenOnMobile}
165
+ />
133
166
  <aside
134
167
  ref={asideRef}
168
+ id={id ?? asideId} // ๐Ÿ†” 0.1.6: target of aria-controls (useAbAsideToggle)
135
169
  className={clsx(
136
170
  ['AbAsideLayout', 'group/aside-layout', styles.abAsideLayout, className],
137
171
  { 'is-open': isAsideOpen },
138
- { 'slideFromRight': isAsideOpen },
172
+ { 'slideFromRight': isAsideOpen || isAsideOpenOnMobile }, // ๐ŸŽž๏ธ ab-nextjs-animations keyframe (240ms here, none under reduced motion)
139
173
  { 'is-open-on-mobile': isAsideOpenOnMobile },
140
- { '!hidden': !isAsideOpen && !isAsideOpening && !isAsideClosing },
174
+ // ๐Ÿ™ˆ 0.1.6: stay mounted-visible while the mobile/tablet sheet is open, even if the laptop pane is closed
175
+ { '!hidden': !isAsideOpen && !isAsideOpenOnMobile && !isAsideOpening && !isAsideClosing },
141
176
  )}
142
177
  hidden={hidden}
143
178
  data-page-name={pageName}
@@ -145,9 +180,11 @@ const AbAsideLayout = ({
145
180
  data-opened={isAsideOpen}
146
181
  data-open-on-mobile={isAsideOpenOnMobile}
147
182
  >
148
- <header className={clsx(['HeaderWrapper', styles.abAsideLayout__headerWrapper])}>
149
- {header}
150
- </header>
183
+ {top != null && ( // ๐Ÿซฅ not rendered when empty (D37)
184
+ <header className={clsx(['HeaderWrapper', styles.abAsideLayout__headerWrapper])} data-ab-part="aside-header">
185
+ {top}
186
+ </header>
187
+ )}
151
188
 
152
189
  <div
153
190
  className={clsx([
@@ -155,35 +192,44 @@ const AbAsideLayout = ({
155
192
  styles.abAsideLayout__contentWrapper,
156
193
  orientation === 'horizontal' && '!flex-row',
157
194
  ])}
195
+ data-ab-part="aside-content" // ๐Ÿ“œ G51/G55: THE scroller of this pane (useAbScrollSpy root)
196
+ ref={setAsideScrollElement} // ๐Ÿ“œ G55: useAbPage().asideScrollElement
158
197
  >
159
198
  {content ?? children}
160
199
  </div>
161
200
 
162
- <footer className={clsx(['FooterWrapper', styles.abAsideLayout__footerWrapper])}>
163
- {footer}
164
- </footer>
165
-
166
- <div className={clsx(['Backdrop', styles.abAsideLayout__backdrop])} hidden />
167
- <div className={clsx(['Menus', styles.abAsideLayout__menus])} hidden>
168
- {menus}
169
- </div>
170
- <div className={clsx(['Dialogs', styles.abAsideLayout__dialogs])} hidden />
171
- <div className={clsx(['Toasts', styles.abAsideLayout__toasts])} hidden />
172
-
173
- {!dividerHidden && (
174
- <span className={clsx(['divider', 'vertical', 'left', styles.abAsideLayout__divider])} />
201
+ {bar != null && ( // ๐Ÿซฅ not rendered when empty (D37)
202
+ <footer className={clsx(['FooterWrapper', styles.abAsideLayout__footerWrapper])} data-ab-part="aside-bar">
203
+ {bar}
204
+ </footer>
175
205
  )}
176
206
 
177
- <AbLinearProgress
178
- className="absolute top-0 left-0 right-0 z-50 !h-0.5 lg:!h-1"
179
- color="secondary"
180
- hidden={!isAsideLoading}
181
- isIndeterminate={true}
182
- progress={25}
183
- />
184
-
185
- <canvas className={clsx(['Canvas', styles.abAsideLayout__canvas])} />
207
+ {chrome && ( // ๐Ÿงฐ 0.1.6: legacy layers
208
+ <>
209
+ <div className={clsx(['Backdrop', styles.abAsideLayout__backdrop])} hidden />
210
+ <div className={clsx(['Menus', styles.abAsideLayout__menus])} hidden>
211
+ {menus}
212
+ </div>
213
+ <div className={clsx(['Dialogs', styles.abAsideLayout__dialogs])} hidden />
214
+ <div className={clsx(['Toasts', styles.abAsideLayout__toasts])} hidden />
215
+
216
+ {!dividerHidden && (
217
+ <span className={clsx(['divider', 'vertical', 'left', styles.abAsideLayout__divider])} />
218
+ )}
219
+
220
+ <AbLinearProgress
221
+ className="absolute top-0 left-0 right-0 z-50 !h-0.5 lg:!h-1"
222
+ color="secondary"
223
+ hidden={!isAsideLoading}
224
+ isIndeterminate={true}
225
+ progress={25}
226
+ />
227
+
228
+ <canvas className={clsx(['Canvas', styles.abAsideLayout__canvas])} />
229
+ </>
230
+ )}
186
231
  </aside>
232
+ </>
187
233
  );
188
234
  };
189
235