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 +20 -0
- package/README.md +48 -2
- package/ab-app-layout/index.tsx +81 -22
- package/ab-app-layout/styles.module.css +218 -1
- package/ab-aside-layout/index.tsx +76 -30
- package/ab-aside-layout/styles.module.css +54 -1
- package/ab-linear-progress/index.tsx +1 -1
- package/ab-main-layout/index.tsx +57 -23
- package/ab-main-layout/styles.module.css +43 -1
- package/ab-page-provider/index.tsx +144 -6
- package/ab-page-provider/scroll-registrar.tsx +71 -0
- package/ab-page-provider/slot.ts +51 -0
- package/ab-screen-layout/index.tsx +3 -3
- package/ab-screen-layout/styles.module.css +1 -1
- package/index.ts +1 -1
- package/package.json +4 -3
- package/server/ab-app-layout/index.tsx +56 -25
- package/server/ab-app-layout/styles.module.css +218 -1
- package/server/ab-aside-layout/index.tsx +62 -28
- package/server/ab-aside-layout/styles.module.css +43 -1
- package/server/ab-main-layout/index.tsx +59 -25
- package/server/ab-main-layout/styles.module.css +43 -1
- package/server/ab-screen-layout/index.tsx +3 -3
- package/server/ab-screen-layout/styles.module.css +1 -1
|
@@ -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 <
|
|
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
|
+
}
|
package/ab-main-layout/index.tsx
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* SOFTWARE.
|
|
25
25
|
*
|
|
26
26
|
* @project: ab-nextjs-core
|
|
27
|
-
* @authors: Abraham Ukachi <
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
{header
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 <
|
|
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 <
|
|
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 =
|
|
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
|
-
|
|
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),
|
|
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
|
-
},
|
|
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 <
|
|
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 />
|