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.
@@ -27,7 +27,7 @@
27
27
  * @name: AB Aside Layout (client) - Component Stylesheet (plain CSS, Tailwind-independent)
28
28
  * @file: ab-aside-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
  /*
@@ -50,6 +50,17 @@
50
50
  transition-duration: 300ms;
51
51
  transition-timing-function: ease-in-out;
52
52
  background-color: var(--color-background, var(--surface, #ffffff));
53
+
54
+ /* ๐ŸŽž๏ธ 0.1.6: the slideFromRight keyframe (ab-nextjs-animations) runs 240ms here (brief ยง4) */
55
+ --slide-from-right-animation-duration: 240ms;
56
+ }
57
+
58
+ /* โ™ฟ 0.1.6: reduced motion = instant toggle, no slide (the provider also drops its timers to 0) */
59
+ @media (prefers-reduced-motion: reduce) {
60
+ .abAsideLayout {
61
+ animation: none !important; /* ๐Ÿšซ beats the global .slideFromRight class */
62
+ transition: none;
63
+ }
53
64
  }
54
65
 
55
66
  @media (min-width: 1536px) {
@@ -158,3 +169,45 @@
158
169
  from { opacity: 0; }
159
170
  to { opacity: 1; }
160
171
  }
172
+
173
+ /* ================================================================== */
174
+ /* ๐Ÿ“ 0.1.6 layout rule (G51 + G52 + G54): a bounded box whose CONTENT */
175
+ /* scrolls on its own; aside-header / aside-bar collapse when empty */
176
+ /* ================================================================== */
177
+ /* ๐Ÿซฅ D37: an empty slot is 0px (also outside panes: an empty wrapper had no size anyway) */
178
+ .abAsideLayout__headerWrapper:empty,
179
+ .abAsideLayout__footerWrapper:empty,
180
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__headerWrapper:not(:has(> *)),
181
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__footerWrapper:not(:has(> *)) {
182
+ display: none; /* ๐Ÿ“ 0px, and no footer gradient */
183
+ }
184
+ /* ๐Ÿงญ inside AbAppLayout variant="panes": a full-height column, only the content wrapper scrolls */
185
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout {
186
+ height: 100%; /* ๐Ÿ“ the pane */
187
+ min-height: 0; /* ๐Ÿšซ */
188
+ max-height: none; /* ๐Ÿงน the legacy 100vh caps */
189
+ overflow: hidden; /* ๐Ÿ”’ */
190
+ display: flex; /* ๐Ÿงฑ header ยท content ยท bar */
191
+ flex-direction: column;
192
+ margin: 0; /* ๐Ÿงน legacy 2xl margins */
193
+ }
194
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__headerWrapper,
195
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__footerWrapper {
196
+ flex: none; /* ๐Ÿ“ rows size to their content: no hard-coded heights */
197
+ pointer-events: auto; /* ๐Ÿ–ฑ๏ธ real chrome, not an overlay */
198
+ }
199
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__footerWrapper {
200
+ padding-bottom: env(safe-area-inset-bottom, 0px); /* ๐Ÿ“ฑ home indicator */
201
+ }
202
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__footerWrapper::before {
203
+ content: none; /* ๐Ÿงน no gradient over the scroller */
204
+ }
205
+ :global(.AbAppLayout[data-variant="panes"]) .abAsideLayout__contentWrapper {
206
+ flex: 1 1 0; /* โ†•๏ธ the rest */
207
+ height: auto; /* ๐Ÿ“ */
208
+ min-height: 0; /* ๐Ÿšซ lets it scroll */
209
+ overflow: hidden auto; /* ๐Ÿ“œ THE scroller of this pane */
210
+ overscroll-behavior: contain; /* ๐Ÿšซ never chains to the page (G54) */
211
+ scrollbar-gutter: stable; /* ๐Ÿ“ no layout jump */
212
+ justify-content: flex-start; /* โฌ†๏ธ */
213
+ }
@@ -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
  /*
@@ -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
  /*
@@ -38,7 +38,10 @@
38
38
  import type { ReactElement, ReactNode } from 'react';
39
39
  import clsx from 'clsx';
40
40
  import AbLinearProgress from '../ab-linear-progress';
41
+ import { useAbPage } from '../ab-page-provider';
41
42
  import styles from './styles.module.css';
43
+ // ๐Ÿซฅ G52: empty-slot helper (strings get a <span>, so the :has() collapse never hides text)
44
+ import { toAbSlot } from '../ab-page-provider/slot';
42
45
 
43
46
  export interface AbMainLayoutProps {
44
47
  orientation: 'vertical' | 'horizontal';
@@ -46,11 +49,22 @@ export interface AbMainLayoutProps {
46
49
  hidden?: boolean;
47
50
  pageName?: string;
48
51
  screenName?: string;
52
+ /** ๐Ÿ” 0.1.6 (G52): the main pane's header slot (data-ab-part="main-header"); 0px and not rendered when empty. */
53
+ mainHeader?: ReactNode;
54
+ /** ๐Ÿ”š 0.1.6 (G52): the main pane's bottom bar slot (data-ab-part="main-bar"); 0px and not rendered when empty. */
55
+ mainBar?: ReactNode;
56
+ /** @deprecated 0.1.6: use `mainHeader` (kept as an alias for one minor). */
49
57
  header?: ReactNode;
50
58
  content?: ReactNode;
59
+ /** @deprecated 0.1.6: use `mainBar` (kept as an alias for one minor). */
51
60
  footer?: ReactNode;
52
61
  children?: ReactNode;
53
62
  menus?: ReactNode;
63
+ /** ๐Ÿ†” 0.1.6: element id (e.g. the skip-link target), no inner wrapper needed. */
64
+ id?: string;
65
+ /** @deprecated 0.1.6: `false` drops the legacy backdrop / menus / dialogs / toasts / progress / canvas layers.
66
+ * Empty header / bar slots are never rendered any more, so this only gates those layers (default `true`). */
67
+ chrome?: boolean;
54
68
  }
55
69
 
56
70
  /** Client markup mirror of server AbMainLayout. */
@@ -60,14 +74,24 @@ const AbMainLayout = ({
60
74
  hidden,
61
75
  pageName,
62
76
  screenName,
63
- header,
77
+ mainHeader,
78
+ mainBar,
79
+ header, // ๐Ÿ•ฐ๏ธ deprecated alias
64
80
  content,
65
- footer,
81
+ footer, // ๐Ÿ•ฐ๏ธ deprecated alias
66
82
  children,
67
83
  menus,
84
+ id, // ๐Ÿ†”
85
+ chrome = true, // ๐Ÿงฐ
68
86
  }: AbMainLayoutProps): ReactElement => {
87
+ // ๐Ÿ”๐Ÿ”š G52: the new slot names win, the 0.1.5 names still work
88
+ const top = toAbSlot(mainHeader ?? header);
89
+ const bar = toAbSlot(mainBar ?? footer);
90
+ // ๐Ÿ“œ G55: publish this pane's scroller (the content wrapper) so scroll spies / TOCs can use it as `root`
91
+ const { setMainScrollElement } = useAbPage();
69
92
  return (
70
93
  <main
94
+ id={id} // ๐Ÿ†” 0.1.6
71
95
  className={clsx(
72
96
  ['AbMainLayout', 'group-mainLayout', styles.abMainLayout, className],
73
97
  { 'has-screen-name': !!screenName },
@@ -78,36 +102,46 @@ const AbMainLayout = ({
78
102
  data-ab-part="main"
79
103
  data-screen-name={screenName}
80
104
  >
81
- <header className={clsx(['HeaderWrapper', styles.abMainLayout__headerWrapper])}>
82
- {header}
83
- </header>
105
+ {top != null && ( // ๐Ÿซฅ not rendered when empty (D37)
106
+ <header className={clsx(['HeaderWrapper', styles.abMainLayout__headerWrapper])} data-ab-part="main-header">
107
+ {top}
108
+ </header>
109
+ )}
84
110
  <div
85
111
  className={clsx([
86
112
  'ContentWrapper',
87
113
  styles.abMainLayout__contentWrapper,
88
114
  orientation === 'horizontal' && 'md:!flex-row',
89
115
  ])}
116
+ data-ab-part="main-content" // ๐Ÿ“œ G51/G55: THE scroller of this pane (useAbScrollSpy root)
117
+ ref={setMainScrollElement} // ๐Ÿ“œ G55: useAbPage().mainScrollElement
90
118
  data-orientation={orientation ? orientation.toString() : undefined}
91
119
  >
92
120
  {content ?? children}
93
121
  </div>
94
- <footer className={clsx(['FooterWrapper', styles.abMainLayout__footerWrapper])}>
95
- {footer}
96
- </footer>
97
- <div className={clsx(['Backdrop', styles.abMainLayout__backdrop])} hidden />
98
- <div className={clsx(['Menus', styles.abMainLayout__menus])} hidden>
99
- {menus}
100
- </div>
101
- <div className={clsx(['Dialogs', styles.abMainLayout__dialogs])} hidden />
102
- <div className={clsx(['Toasts', styles.abMainLayout__toasts])} hidden />
103
- <AbLinearProgress
104
- className="absolute top-0 left-0 right-0 z-50 !w-1/2 place-self-center !my-1 3xl:!my-2 !mx-auto !h-0.5 lg:!h-1 3xl:!h-1.5"
105
- color="secondary"
106
- hidden={true}
107
- isIndeterminate={true}
108
- progress={25}
109
- />
110
- <canvas className={clsx(['Canvas', styles.abMainLayout__canvas])} />
122
+ {bar != null && ( // ๐Ÿซฅ not rendered when empty (D37)
123
+ <footer className={clsx(['FooterWrapper', styles.abMainLayout__footerWrapper])} data-ab-part="main-bar">
124
+ {bar}
125
+ </footer>
126
+ )}
127
+ {chrome && ( // ๐Ÿงฐ 0.1.6: legacy layers
128
+ <>
129
+ <div className={clsx(['Backdrop', styles.abMainLayout__backdrop])} hidden />
130
+ <div className={clsx(['Menus', styles.abMainLayout__menus])} hidden>
131
+ {menus}
132
+ </div>
133
+ <div className={clsx(['Dialogs', styles.abMainLayout__dialogs])} hidden />
134
+ <div className={clsx(['Toasts', styles.abMainLayout__toasts])} hidden />
135
+ <AbLinearProgress
136
+ className="absolute top-0 left-0 right-0 z-50 !w-1/2 place-self-center !my-1 3xl:!my-2 !mx-auto !h-0.5 lg:!h-1 3xl:!h-1.5"
137
+ color="secondary"
138
+ hidden={true}
139
+ isIndeterminate={true}
140
+ progress={25}
141
+ />
142
+ <canvas className={clsx(['Canvas', styles.abMainLayout__canvas])} />
143
+ </>
144
+ )}
111
145
  </main>
112
146
  );
113
147
  };
@@ -27,7 +27,7 @@
27
27
  * @name: AB Main Layout - Component Stylesheet (plain CSS, Tailwind-independent)
28
28
  * @file: ab-main-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
  /*
@@ -165,3 +165,45 @@
165
165
  from { opacity: 0; }
166
166
  to { opacity: 1; }
167
167
  }
168
+
169
+ /* ================================================================== */
170
+ /* ๐Ÿ“ 0.1.6 layout rule (G51 + G52 + G54): a bounded box whose CONTENT */
171
+ /* scrolls on its own; main-header / main-bar collapse when empty */
172
+ /* ================================================================== */
173
+ /* ๐Ÿซฅ D37: an empty slot is 0px (also outside panes: an empty wrapper had no size anyway) */
174
+ .abMainLayout__headerWrapper:empty,
175
+ .abMainLayout__footerWrapper:empty,
176
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__headerWrapper:not(:has(> *)),
177
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__footerWrapper:not(:has(> *)) {
178
+ display: none; /* ๐Ÿ“ 0px, and no footer gradient */
179
+ }
180
+ /* ๐Ÿงญ inside AbAppLayout variant="panes": a full-height column, only the content wrapper scrolls */
181
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout {
182
+ height: 100%; /* ๐Ÿ“ the pane */
183
+ min-height: 0; /* ๐Ÿšซ */
184
+ max-height: none; /* ๐Ÿงน the legacy 100vh caps */
185
+ overflow: hidden; /* ๐Ÿ”’ */
186
+ display: flex; /* ๐Ÿงฑ header ยท content ยท bar */
187
+ flex-direction: column;
188
+ margin: 0; /* ๐Ÿงน legacy 2xl margins */
189
+ }
190
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__headerWrapper,
191
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__footerWrapper {
192
+ flex: none; /* ๐Ÿ“ rows size to their content: no hard-coded heights */
193
+ pointer-events: auto; /* ๐Ÿ–ฑ๏ธ real chrome, not an overlay */
194
+ }
195
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__footerWrapper {
196
+ padding-bottom: env(safe-area-inset-bottom, 0px); /* ๐Ÿ“ฑ home indicator */
197
+ }
198
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__footerWrapper::before {
199
+ content: none; /* ๐Ÿงน no gradient over the scroller */
200
+ }
201
+ :global(.AbAppLayout[data-variant="panes"]) .abMainLayout__contentWrapper {
202
+ flex: 1 1 0; /* โ†•๏ธ the rest */
203
+ height: auto; /* ๐Ÿ“ */
204
+ min-height: 0; /* ๐Ÿšซ lets it scroll */
205
+ overflow: hidden auto; /* ๐Ÿ“œ THE scroller of this pane */
206
+ overscroll-behavior: contain; /* ๐Ÿšซ never chains to the page (G54) */
207
+ scrollbar-gutter: stable; /* ๐Ÿ“ no layout jump */
208
+ justify-content: flex-start; /* โฌ†๏ธ */
209
+ }
@@ -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
  /*
@@ -39,13 +39,22 @@ import {
39
39
  createContext,
40
40
  useCallback,
41
41
  useContext,
42
+ useEffect,
42
43
  useMemo,
44
+ useRef,
43
45
  useState,
44
46
  type ReactElement,
45
47
  type ReactNode,
46
48
  } from 'react';
47
49
 
48
50
  export interface AbPageContextValue {
51
+ /** ๐Ÿ“œ 0.1.6 (G55): the main pane's scroller (AbMainLayout's content wrapper), e.g. `useAbScrollSpy({ root })`. */
52
+ mainScrollElement: HTMLElement | null;
53
+ /** ๐Ÿ“œ 0.1.6 (G55): the aside pane's scroller. */
54
+ asideScrollElement: HTMLElement | null;
55
+ /** ๐Ÿ”— ref callbacks used by AbMainLayout / AbAsideLayout. */
56
+ setMainScrollElement: (el: HTMLElement | null) => void;
57
+ setAsideScrollElement: (el: HTMLElement | null) => void;
49
58
  isAsideOpen: boolean;
50
59
  isAsideOpenOnMobile: boolean;
51
60
  asideDuration: number;
@@ -57,9 +66,38 @@ export interface AbPageContextValue {
57
66
  setAsideOpen: (open: boolean) => void;
58
67
  setAsideOpenOnMobile: (open: boolean) => void;
59
68
  setAsideLoading: (loading: boolean) => void;
69
+ /** ๐Ÿ†” 0.1.6: id of the aside element (for `aria-controls` on the TOC / ยปยซ buttons). */
70
+ asideId: string;
71
+ /** ๐Ÿ” 0.1.6: flip the laptop+ aside (ยป/ยซ toggle). */
72
+ toggleAside: () => void;
73
+ /** ๐Ÿ“ฑ 0.1.6: flip the mobile/tablet aside sheet (TOC button). */
74
+ toggleAsideOnMobile: () => void;
75
+ }
76
+
77
+ /** ๐ŸŽž๏ธ 0.1.6: aside slide duration (brief ยง4: slide-from-right 240ms). */
78
+ export const AB_ASIDE_DURATION = 240;
79
+ /** ๐Ÿ“ 0.1.6: below this the aside is a sheet driven by `isAsideOpenOnMobile` (mobile + tablet). */
80
+ export const AB_ASIDE_SHEET_QUERY = '(max-width: 1023px)';
81
+ /** โ™ฟ reduced-motion query (same string as ab-nextjs-hooks' AB_REDUCED_MOTION_QUERY). */
82
+ export const AB_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
83
+
84
+ // ๐Ÿ” one-shot media check (false on the server / without matchMedia)
85
+ const matches = (query: string): boolean =>
86
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(query).matches;
87
+
88
+ /**
89
+ * โฑ๏ธ getAbAsideDuration โ€” the duration the open/close timers should use: `0` under reduced motion
90
+ * (instant toggle, brief ยง1.4), otherwise the provider's duration.
91
+ */
92
+ export function getAbAsideDuration(duration: number, reducedMotion: boolean): number {
93
+ return reducedMotion ? 0 : Math.max(0, duration); // โ™ฟ instant, never negative
60
94
  }
61
95
 
62
96
  const defaultValue: AbPageContextValue = {
97
+ mainScrollElement: null,
98
+ asideScrollElement: null,
99
+ setMainScrollElement: () => {},
100
+ setAsideScrollElement: () => {},
63
101
  isAsideOpen: true,
64
102
  isAsideOpenOnMobile: false,
65
103
  asideDuration: 300,
@@ -71,6 +109,9 @@ const defaultValue: AbPageContextValue = {
71
109
  setAsideOpen: () => {},
72
110
  setAsideOpenOnMobile: () => {},
73
111
  setAsideLoading: () => {},
112
+ asideId: 'ab-aside',
113
+ toggleAside: () => {},
114
+ toggleAsideOnMobile: () => {},
74
115
  };
75
116
 
76
117
  const AbPageContext = createContext<AbPageContextValue>(defaultValue);
@@ -79,7 +120,10 @@ export interface AbPageProviderProps {
79
120
  children?: ReactNode;
80
121
  /** Initial aside open state (default true so shells render visibly). */
81
122
  defaultAsideOpen?: boolean;
123
+ /** ๐ŸŽž๏ธ slide duration in ms (default 240, 0.1.6; was 300). Reduced motion always makes it instant. */
82
124
  asideDuration?: number;
125
+ /** ๐Ÿ†” aside element id for `aria-controls` (default `'ab-aside'`). */
126
+ asideId?: string;
83
127
  }
84
128
 
85
129
  /**
@@ -89,7 +133,8 @@ export interface AbPageProviderProps {
89
133
  export function AbPageProvider({
90
134
  children,
91
135
  defaultAsideOpen = true,
92
- asideDuration = 300,
136
+ asideDuration = AB_ASIDE_DURATION,
137
+ asideId = 'ab-aside',
93
138
  }: AbPageProviderProps): ReactElement {
94
139
  const [isAsideOpen, setAsideOpen] = useState(defaultAsideOpen);
95
140
  const [isAsideOpenOnMobile, setAsideOpenOnMobile] = useState(false);
@@ -97,27 +142,79 @@ export function AbPageProvider({
97
142
  const [isAsideClosing, setAsideClosing] = useState(false);
98
143
  const [isAsideLoading, setAsideLoading] = useState(false);
99
144
  const [asideElement, setAsideElementState] = useState<HTMLElement | null>(null);
145
+ // ๐Ÿ“œ G55: the two pane scrollers (state, so consumers re-render once they mount)
146
+ const [mainScrollElement, setMainScrollElement] = useState<HTMLElement | null>(null);
147
+ const [asideScrollElement, setAsideScrollElement] = useState<HTMLElement | null>(null);
100
148
 
101
149
  const setAsideElement = useCallback((el: HTMLElement | null) => {
102
150
  setAsideElementState(el);
103
151
  }, []);
104
152
 
153
+ // โฒ๏ธ 0.1.6 (James review): ONE pending open/close timer, so quick toggles never race
154
+ const asideTimerRef = useRef<number | undefined>(undefined);
155
+ useEffect(() => () => window.clearTimeout(asideTimerRef.current), []); // ๐Ÿงน unmount
156
+
105
157
  const setAsideOpenSafe = useCallback((open: boolean) => {
158
+ // โ™ฟ 0.1.6: instant under reduced motion (no slide, no closing delay)
159
+ const ms = getAbAsideDuration(asideDuration, matches(AB_REDUCED_MOTION_QUERY));
160
+ window.clearTimeout(asideTimerRef.current); // ๐Ÿ›‘ cancel the previous transition's timer
106
161
  if (open) {
107
- setAsideOpening(true);
162
+ setAsideClosing(false); // ๐Ÿ” a close in flight is cancelled
163
+ setAsideOpening(ms > 0); // ๐ŸŽž๏ธ only "opening" when it animates
108
164
  setAsideOpen(true);
109
- window.setTimeout(() => setAsideOpening(false), asideDuration);
165
+ if (ms > 0) asideTimerRef.current = window.setTimeout(() => setAsideOpening(false), ms);
166
+ } else if (ms === 0) {
167
+ setAsideOpening(false); // ๐Ÿ”
168
+ setAsideClosing(false); // ๐Ÿ”
169
+ setAsideOpen(false); // โšก instant
110
170
  } else {
171
+ setAsideOpening(false); // ๐Ÿ” an open in flight is cancelled
111
172
  setAsideClosing(true);
112
- window.setTimeout(() => {
173
+ asideTimerRef.current = window.setTimeout(() => {
113
174
  setAsideOpen(false);
114
175
  setAsideClosing(false);
115
- }, asideDuration);
176
+ }, ms);
116
177
  }
117
178
  }, [asideDuration]);
118
179
 
180
+ // ๐Ÿ” 0.1.6: toggles for the ยป/ยซ button (laptop+) and the TOC button (mobile/tablet sheet)
181
+ const toggleAside = useCallback(() => setAsideOpenSafe(!isAsideOpen), [isAsideOpen, setAsideOpenSafe]);
182
+ const toggleAsideOnMobile = useCallback(() => setAsideOpenOnMobile((v) => !v), []);
183
+
184
+ // โŽ‹ 0.1.6: Escape closes the mobile/tablet sheet, unless an inner menu/dialog already handled it
185
+ useEffect(() => {
186
+ if (!isAsideOpenOnMobile) return; // ๐Ÿ’ค only while the sheet is open
187
+ const onKey = (e: KeyboardEvent) => {
188
+ if (e.key !== 'Escape' || e.defaultPrevented) return; // ๐Ÿ™… an inner menu caught it (James review)
189
+ setAsideOpenOnMobile(false);
190
+ };
191
+ window.addEventListener('keydown', onKey);
192
+ return () => window.removeEventListener('keydown', onKey);
193
+ }, [isAsideOpenOnMobile]);
194
+
195
+ // ๐ŸŽฏ 0.1.6 (James review): move focus INTO the sheet when it opens, and back to the opener when it closes
196
+ const sheetOpenerRef = useRef<HTMLElement | null>(null);
197
+ useEffect(() => {
198
+ if (typeof document === 'undefined') return;
199
+ if (isAsideOpenOnMobile) {
200
+ sheetOpenerRef.current = document.activeElement as HTMLElement | null; // ๐Ÿ’พ the TOC button
201
+ const el = asideElement;
202
+ if (el) {
203
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1'); // ๐ŸŽฏ focusable container
204
+ el.focus({ preventScroll: true });
205
+ }
206
+ } else if (sheetOpenerRef.current) {
207
+ sheetOpenerRef.current.focus?.({ preventScroll: true }); // โ†ฉ๏ธ return focus
208
+ sheetOpenerRef.current = null;
209
+ }
210
+ }, [isAsideOpenOnMobile, asideElement]);
211
+
119
212
  const value = useMemo<AbPageContextValue>(
120
213
  () => ({
214
+ mainScrollElement,
215
+ asideScrollElement,
216
+ setMainScrollElement,
217
+ setAsideScrollElement,
121
218
  isAsideOpen,
122
219
  isAsideOpenOnMobile,
123
220
  asideDuration,
@@ -129,8 +226,13 @@ export function AbPageProvider({
129
226
  setAsideOpen: setAsideOpenSafe,
130
227
  setAsideOpenOnMobile,
131
228
  setAsideLoading,
229
+ asideId,
230
+ toggleAside,
231
+ toggleAsideOnMobile,
132
232
  }),
133
233
  [
234
+ mainScrollElement,
235
+ asideScrollElement,
134
236
  isAsideOpen,
135
237
  isAsideOpenOnMobile,
136
238
  asideDuration,
@@ -140,6 +242,9 @@ export function AbPageProvider({
140
242
  isAsideClosing,
141
243
  isAsideLoading,
142
244
  setAsideOpenSafe,
245
+ asideId,
246
+ toggleAside,
247
+ toggleAsideOnMobile,
143
248
  ],
144
249
  );
145
250
 
@@ -154,4 +259,37 @@ export function useAbPage(): AbPageContextValue {
154
259
  return useContext(AbPageContext);
155
260
  }
156
261
 
262
+ /** ๐ŸŽ›๏ธ What {@link useAbAsideToggle} returns: spread `props` on the button. */
263
+ export interface AbAsideToggle {
264
+ /** ๐Ÿ“ true below 1024px (the aside is a sheet, driven by `isAsideOpenOnMobile`). */
265
+ isSheet: boolean;
266
+ /** ๐Ÿ‘€ is the aside shown for the current viewport? */
267
+ expanded: boolean;
268
+ /** ๐Ÿ” flip it for the current viewport. */
269
+ toggle: () => void;
270
+ /** โ™ฟ `aria-expanded` + `aria-controls` + `onClick` for a toggle button. */
271
+ props: { 'aria-expanded': boolean; 'aria-controls': string; onClick: () => void };
272
+ }
273
+
274
+ /**
275
+ * ๐Ÿ“‘ useAbAsideToggle (0.1.6) โ€” one toggle for the TOC button (mobile/tablet sheet) and the ยป/ยซ button
276
+ * (laptop+ pane). It picks the right state for the viewport and keeps `aria-expanded` / `aria-controls` right.
277
+ */
278
+ export function useAbAsideToggle(): AbAsideToggle {
279
+ const { isAsideOpen, isAsideOpenOnMobile, asideId, toggleAside, toggleAsideOnMobile } = useAbPage();
280
+ // ๐Ÿ“ live viewport class (SSR: laptop+, so the server markup matches the default open pane)
281
+ const [isSheet, setSheet] = useState(false);
282
+ useEffect(() => {
283
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
284
+ const mq = window.matchMedia(AB_ASIDE_SHEET_QUERY); // ๐Ÿ“
285
+ const update = () => setSheet(mq.matches);
286
+ update();
287
+ mq.addEventListener?.('change', update);
288
+ return () => mq.removeEventListener?.('change', update);
289
+ }, []);
290
+ const expanded = isSheet ? isAsideOpenOnMobile : isAsideOpen; // ๐Ÿ‘€
291
+ const toggle = isSheet ? toggleAsideOnMobile : toggleAside; // ๐Ÿ”
292
+ return { isSheet, expanded, toggle, props: { 'aria-expanded': expanded, 'aria-controls': asideId, onClick: toggle } };
293
+ }
294
+
157
295
  export default AbPageProvider;
@@ -0,0 +1,71 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-core
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the 'Software'), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+ *
26
+ * @project: ab-nextjs-core
27
+ * @name: AbScrollRegistrar (G55)
28
+ * @file: ab-page-provider/scroll-registrar.tsx
29
+ * @type: Client component
30
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
31
+ */
32
+
33
+ /*
34
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
35
+ * MOTTO: We'll always do more ๐Ÿ˜œ!!!
36
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
37
+ */
38
+
39
+
40
+ 'use client';
41
+
42
+ // โš›๏ธ React
43
+ import { useEffect, useRef, type ReactElement } from 'react';
44
+ // ๐Ÿง  the page context (no-op setters outside an AbPageProvider)
45
+ import { useAbPage } from './index';
46
+
47
+ /** โš™๏ธ props */
48
+ export interface AbScrollRegistrarProps {
49
+ pane: 'main' | 'aside'; // ๐Ÿ“œ which scroller to publish
50
+ }
51
+
52
+ /**
53
+ * ๐Ÿ“Œ AbScrollRegistrar โ€” a tiny client island for the SERVER `AbMainLayout` / `AbAsideLayout` (G55, Joseph).
54
+ * Server components can't hold a ref, so the server layouts render this as the first child of their content
55
+ * wrapper: it publishes its parent (the `data-ab-part="main-content"` / `"aside-content"` scroller) on
56
+ * `useAbPage()` as `mainScrollElement` / `asideScrollElement`, in an effect, and clears it on unmount.
57
+ * It renders one hidden, empty `<span>` (no layout, no a11y tree).
58
+ */
59
+ export function AbScrollRegistrar({ pane }: AbScrollRegistrarProps): ReactElement {
60
+ const { setMainScrollElement, setAsideScrollElement } = useAbPage(); // ๐Ÿง 
61
+ const ref = useRef<HTMLSpanElement>(null); // ๐Ÿ“
62
+ useEffect(() => {
63
+ const set = pane === 'main' ? setMainScrollElement : setAsideScrollElement; // ๐ŸŽฏ
64
+ set(ref.current?.parentElement ?? null); // ๐Ÿ“œ the content wrapper = the scroller
65
+ return () => set(null); // ๐Ÿงน
66
+ }, [pane, setMainScrollElement, setAsideScrollElement]);
67
+ return <span ref={ref} hidden data-ab-part="scroll-registrar" />; // ๐Ÿซฅ
68
+ }
69
+
70
+ // ๐Ÿ“ฆ default export
71
+ export default AbScrollRegistrar;
@@ -0,0 +1,51 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-core
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the 'Software'), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+ *
26
+ * @project: ab-nextjs-core
27
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
28
+ */
29
+
30
+ /*
31
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
32
+ * MOTTO: We'll always do more ๐Ÿ˜œ!!!
33
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
34
+ */
35
+
36
+ // ๐Ÿซฅ 0.1.6 (G52, D37): server-safe (no 'use client'), shared by the client and server layouts
37
+
38
+ import { createElement, type ReactNode } from 'react';
39
+
40
+ /**
41
+ * ๐Ÿซฅ toAbSlot โ€” normalise a slot for rendering:
42
+ * ยท `null` / `undefined` / `false` / `''` / `[]` โ†’ `null`, so the wrapper is NOT rendered (0px)
43
+ * ยท a bare string / number โ†’ wrapped in a `<span>`, so the CSS `:not(:has(> *))` collapse never hides text
44
+ * ยท anything else (elements, fragments, arrays with content) โ†’ returned as is
45
+ */
46
+ export function toAbSlot(node: ReactNode): ReactNode {
47
+ if (node == null || node === false || node === true || node === '') return null; // ๐Ÿ™… empty
48
+ if (Array.isArray(node) && node.every((n) => n == null || n === false || n === true || n === '')) return null; // ๐Ÿ™… empty list
49
+ if (typeof node === 'string' || typeof node === 'number') return createElement('span', { 'data-ab-part': 'slot-text' }, node); // ๐Ÿ”ค
50
+ return node; // โœ…
51
+ }
@@ -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
  /*
@@ -71,13 +71,13 @@ const AbScreenLayout = ({
71
71
  data-screen-name={screenName}
72
72
  data-orientation={orientation ?? ''}
73
73
  >
74
- <header className={clsx(['HeaderWrapper', styles.abScreenLayout__headerWrapper])}>
74
+ <header className={clsx(['HeaderWrapper', styles.abScreenLayout__headerWrapper])} data-ab-part="header">
75
75
  {header}
76
76
  </header>
77
77
  <div className={clsx(['ContentWrapper', styles.abScreenLayout__contentWrapper])}>
78
78
  {content ?? children}
79
79
  </div>
80
- <footer className={clsx(['FooterWrapper', styles.abScreenLayout__footerWrapper])}>
80
+ <footer className={clsx(['FooterWrapper', styles.abScreenLayout__footerWrapper])} data-ab-part="footer">
81
81
  {footer}
82
82
  </footer>
83
83
  <div id="backdrop" className={clsx(['Backdrop', styles.abScreenLayout__backdrop])} hidden />