ab-nextjs-components 0.1.8 → 0.1.10
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 +42 -0
- package/ab-brand/index.tsx +30 -1
- package/ab-brand/styles.module.css +40 -1
- package/ab-button/helpers.ts +90 -0
- package/ab-button/index.tsx +164 -87
- package/ab-button/styles.module.css +79 -1
- package/ab-carousel/helpers.ts +146 -0
- package/ab-carousel/index.tsx +643 -0
- package/ab-carousel/styles.module.css +206 -0
- package/ab-chip/index.tsx +75 -0
- package/ab-chip/styles.module.css +87 -0
- package/ab-collapsible/index.tsx +30 -1
- package/ab-collapsible/styles.module.css +40 -1
- package/ab-flow-diagram/helpers.ts +77 -0
- package/ab-flow-diagram/index.tsx +141 -0
- package/ab-flow-diagram/styles.module.css +141 -0
- package/ab-icon/builtin.ts +61 -0
- package/ab-icon/helpers.ts +102 -0
- package/ab-icon/index.tsx +77 -16
- package/ab-icon/styles.module.css +53 -9
- package/ab-icon-button/index.tsx +110 -46
- package/ab-icon-button/styles.module.css +28 -10
- package/ab-install-snippet/helpers.ts +62 -0
- package/ab-install-snippet/index.tsx +170 -0
- package/ab-install-snippet/styles.module.css +104 -0
- package/ab-like-button/index.tsx +33 -1
- package/ab-like-button/styles.module.css +41 -6
- package/ab-linear-progress/index.tsx +129 -0
- package/ab-linear-progress/styles.module.css +105 -0
- package/ab-locale-switcher/index.tsx +213 -0
- package/ab-locale-switcher/styles.module.css +127 -0
- package/ab-logo/index.tsx +88 -30
- package/ab-logo/styles.module.css +79 -11
- package/ab-pill/index.tsx +74 -0
- package/ab-progress-chips/index.tsx +30 -1
- package/ab-progress-chips/styles.module.css +40 -1
- package/ab-searchbar/index.tsx +33 -2
- package/ab-searchbar/styles.module.css +41 -5
- package/ab-splash/csp.d.ts +54 -0
- package/ab-splash/csp.mjs +82 -0
- package/ab-splash/csp.ts +60 -0
- package/ab-splash/gate.ts +216 -0
- package/ab-splash/index.tsx +140 -0
- package/ab-splash/styles.module.css +103 -0
- package/ab-theme-toggle/index.tsx +188 -0
- package/ab-theme-toggle/styles.module.css +89 -0
- package/css/dialog.css +38 -3
- package/index.ts +47 -0
- package/package.json +46 -18
- package/server/ab-brand/index.tsx +29 -1
- package/server/ab-brand/styles.module.css +40 -1
- package/server/ab-button/index.tsx +96 -79
- package/server/ab-button/styles.module.css +79 -1
- package/server/ab-icon/index.tsx +68 -16
- package/server/ab-icon/styles.module.css +53 -9
- package/server/ab-logo/index.tsx +88 -29
- package/server/ab-logo/styles.module.css +79 -11
- package/types/css.d.ts +43 -0
- package/types/index.d.ts +2 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @license MIT
|
|
3
|
+
* ~~~~~~~~~~~~
|
|
4
|
+
* ab-nextjs-components
|
|
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-components
|
|
27
|
+
* @name: AbCarousel - Client (G31)
|
|
28
|
+
* @file: ab-carousel/index.tsx
|
|
29
|
+
* @type: Component (client) — compound: AbCarousel + Track / Slide / Prev / Next / Dots / Counter
|
|
30
|
+
* @authors: Abraham Ukachi <abrahamukachi@gmail.com>
|
|
31
|
+
*
|
|
32
|
+
* Example usage:
|
|
33
|
+
* 1+|> <AbCarousel label="Introduction to AbElements" titles={titles}>
|
|
34
|
+
* -|> <AbCarouselTrack>{slides.map((s) => <AbCarouselSlide key={s.id}>{s.body}</AbCarouselSlide>)}</AbCarouselTrack>
|
|
35
|
+
* -|> <footer><AbCarouselDots /><AbCarouselPrev /><AbCarouselNext /></footer>
|
|
36
|
+
* -|> </AbCarousel>
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
41
|
+
* MOTTO: We'll always do more 😜!!!
|
|
42
|
+
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
'use client';
|
|
46
|
+
|
|
47
|
+
// ⚛️ React runtime + types
|
|
48
|
+
import {
|
|
49
|
+
Children,
|
|
50
|
+
createContext,
|
|
51
|
+
Fragment,
|
|
52
|
+
isValidElement,
|
|
53
|
+
useCallback,
|
|
54
|
+
useContext,
|
|
55
|
+
useId,
|
|
56
|
+
useMemo,
|
|
57
|
+
useRef,
|
|
58
|
+
useState,
|
|
59
|
+
useSyncExternalStore,
|
|
60
|
+
type CSSProperties,
|
|
61
|
+
type KeyboardEvent,
|
|
62
|
+
type ReactElement,
|
|
63
|
+
type ReactNode,
|
|
64
|
+
type Ref,
|
|
65
|
+
} from 'react';
|
|
66
|
+
// 🧵 class-name joiner
|
|
67
|
+
import clsx from 'clsx';
|
|
68
|
+
// 👆 G32 swipe hook (single-hook subpath → no whole-barrel import)
|
|
69
|
+
import { useAbSwipe } from 'ab-nextjs-hooks/useAbSwipe';
|
|
70
|
+
// ♿ G32 reduced-motion hook
|
|
71
|
+
import { useAbReducedMotion } from 'ab-nextjs-hooks/useAbReducedMotion';
|
|
72
|
+
// 🧮 pure helpers (also importable from server components via `ab-carousel/helpers`)
|
|
73
|
+
import {
|
|
74
|
+
AB_CAROUSEL_KEY_IGNORE_SELECTOR,
|
|
75
|
+
clampAbCarouselIndex,
|
|
76
|
+
defaultAbCarouselAnnouncement,
|
|
77
|
+
defaultAbCarouselDotLabel,
|
|
78
|
+
defaultAbCarouselSlideLabel,
|
|
79
|
+
getAbCarouselKeyAction,
|
|
80
|
+
getAbCarouselSlidePosition,
|
|
81
|
+
padAbCarouselNumber,
|
|
82
|
+
type AbCarouselChangeReason,
|
|
83
|
+
type AbCarouselDir,
|
|
84
|
+
} from './helpers';
|
|
85
|
+
// 🎨 package-shipped CSS module
|
|
86
|
+
import styles from './styles.module.css';
|
|
87
|
+
// 🔣 AbIcons SVG (G2)
|
|
88
|
+
import AbIcon from '../ab-icon';
|
|
89
|
+
// 🎬 all carousel motion comes from ab-nextjs-animations (>= 0.2.4): direction-aware slide steps + state transitions
|
|
90
|
+
import { getAbSlideStepDirection, AB_SLIDE_STEP_IN_CLASS, AB_SLIDE_STEP_OUT_CLASS, type AbSlideStepDirection } from 'ab-nextjs-animations/slide-step';
|
|
91
|
+
import { AB_MOTION } from 'ab-nextjs-animations/motion';
|
|
92
|
+
import 'ab-nextjs-animations/slide-step/styles.css'; // ➡️⬅️ .ab-slide-step-in / -out
|
|
93
|
+
import 'ab-nextjs-animations/motion/styles.css'; // 🎨 .ab-motion-*
|
|
94
|
+
|
|
95
|
+
// 📦 re-export the helpers + types so client callers need one import
|
|
96
|
+
export * from './helpers';
|
|
97
|
+
|
|
98
|
+
// ===== TYPES ===== //
|
|
99
|
+
|
|
100
|
+
// 📣 metadata passed to onIndexChange
|
|
101
|
+
export interface AbCarouselChange {
|
|
102
|
+
// 📣 what triggered the change
|
|
103
|
+
reason: AbCarouselChangeReason;
|
|
104
|
+
// ⏮️ index before the change
|
|
105
|
+
from: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ⚙️ <AbCarousel /> props
|
|
109
|
+
export interface AbCarouselProps {
|
|
110
|
+
// 🏷️ accessible name of the carousel region (required, e.g. "Introduction to AbElements")
|
|
111
|
+
label: string;
|
|
112
|
+
// 🎛️ controlled active index (pair with onIndexChange)
|
|
113
|
+
index?: number;
|
|
114
|
+
// 🏁 uncontrolled starting index (default 0)
|
|
115
|
+
defaultIndex?: number;
|
|
116
|
+
// 📣 called with the NEW index on every requested change (controlled + uncontrolled)
|
|
117
|
+
onIndexChange?: (index: number, change: AbCarouselChange) => void;
|
|
118
|
+
// 🔢 number of slides; auto-counted from <AbCarouselTrack> children when omitted
|
|
119
|
+
count?: number;
|
|
120
|
+
// 📰 optional slide titles (used by the live region and dot labels)
|
|
121
|
+
titles?: ReadonlyArray<string | undefined>;
|
|
122
|
+
// 🔁 wrap from last to first (default false: Welcome stops at both ends)
|
|
123
|
+
loop?: boolean;
|
|
124
|
+
// 🌍 reading direction; default follows <html dir> live
|
|
125
|
+
dir?: AbCarouselDir;
|
|
126
|
+
// ⌨️ ←/→ + Home/End while focus is inside (default true)
|
|
127
|
+
keyboard?: boolean;
|
|
128
|
+
// 👆 touch swipe on the track (default true)
|
|
129
|
+
swipe?: boolean;
|
|
130
|
+
// 📏 swipe distance threshold in px (default 48)
|
|
131
|
+
swipeThreshold?: number;
|
|
132
|
+
// 🙈 drag-start selector ignored by swipe (default `[data-ab-swipe-ignore]`)
|
|
133
|
+
swipeIgnore?: string;
|
|
134
|
+
// ♿ 'auto' follows prefers-reduced-motion, 'always' forces calm, 'never' always animates (default 'auto')
|
|
135
|
+
reducedMotion?: 'auto' | 'always' | 'never';
|
|
136
|
+
// 🏷️ slide aria-label factory (default "3 of 9")
|
|
137
|
+
slideLabel?: (n: number, total: number) => string;
|
|
138
|
+
// 📣 live-region text factory, or false to disable announcements (default "Slide 3 of 9: Title")
|
|
139
|
+
announce?: ((n: number, total: number, title?: string) => string) | false;
|
|
140
|
+
// 🧱 element for the region (default 'section')
|
|
141
|
+
as?: 'section' | 'div';
|
|
142
|
+
// 🆔 id of the region
|
|
143
|
+
id?: string;
|
|
144
|
+
// 🏷️ extra classes
|
|
145
|
+
className?: string;
|
|
146
|
+
// 🎨 inline style (e.g. `--ab-carousel-duration: 200ms`)
|
|
147
|
+
style?: CSSProperties;
|
|
148
|
+
// 🔗 ref to the region element (React 19 ref-as-prop)
|
|
149
|
+
ref?: Ref<HTMLElement>;
|
|
150
|
+
// 🧩 track, slides, controls and any layout around them
|
|
151
|
+
children?: ReactNode;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 🧠 everything the parts (and useAbCarousel) can read
|
|
155
|
+
export interface AbCarouselContextValue {
|
|
156
|
+
// 📍 active index
|
|
157
|
+
index: number;
|
|
158
|
+
// 🔢 slide count
|
|
159
|
+
count: number;
|
|
160
|
+
// 🔁 wraps?
|
|
161
|
+
loop: boolean;
|
|
162
|
+
// 🌍 resolved reading direction
|
|
163
|
+
dir: AbCarouselDir;
|
|
164
|
+
// ♿ calm mode active?
|
|
165
|
+
reducedMotion: boolean;
|
|
166
|
+
// 🟢 hydrated on the client? (inert / aria-hidden are only applied after hydration → no-JS stays usable)
|
|
167
|
+
hydrated: boolean;
|
|
168
|
+
// 🧭 direction of the last move ('forward' | 'back'); null until the first move (no entry animation on load)
|
|
169
|
+
direction: AbSlideStepDirection | null;
|
|
170
|
+
// ⏮️ the slide that was active before the last move (plays the leave animation); -1 before any move
|
|
171
|
+
previousIndex: number;
|
|
172
|
+
// 🎯 go to an index
|
|
173
|
+
goTo: (index: number, reason?: AbCarouselChangeReason) => void;
|
|
174
|
+
// ⏭️ next slide
|
|
175
|
+
next: (reason?: AbCarouselChangeReason) => void;
|
|
176
|
+
// ⏮️ previous slide
|
|
177
|
+
prev: (reason?: AbCarouselChangeReason) => void;
|
|
178
|
+
// ⏮️ is there a previous slide?
|
|
179
|
+
canPrev: boolean;
|
|
180
|
+
// ⏭️ is there a next slide?
|
|
181
|
+
canNext: boolean;
|
|
182
|
+
// 📰 titles (may be empty)
|
|
183
|
+
titles: ReadonlyArray<string | undefined>;
|
|
184
|
+
// 🏷️ slide label factory
|
|
185
|
+
slideLabel: (n: number, total: number) => string;
|
|
186
|
+
// 🆔 id of the track (aria-controls target)
|
|
187
|
+
trackId: string;
|
|
188
|
+
// 🆔 id of slide i
|
|
189
|
+
slideId: (i: number) => string;
|
|
190
|
+
// 👆 swipe settings (read by the track)
|
|
191
|
+
swipe: { enabled: boolean; threshold?: number; ignore?: string };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 🧠 the context (null outside <AbCarousel>)
|
|
195
|
+
const AbCarouselContext = createContext<AbCarouselContextValue | null>(null);
|
|
196
|
+
// 🔢 per-slide index context (set by the track)
|
|
197
|
+
const AbCarouselSlideIndexContext = createContext<number>(-1);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 🪝 useAbCarousel — read the carousel state from any element inside <AbCarousel>.
|
|
201
|
+
* Use it for custom controls such as the Welcome "Skip" link: `const { goTo, count } = useAbCarousel()`.
|
|
202
|
+
*/
|
|
203
|
+
export function useAbCarousel(): AbCarouselContextValue {
|
|
204
|
+
// 🔍 nearest carousel
|
|
205
|
+
const ctx = useContext(AbCarouselContext);
|
|
206
|
+
// 💥 used outside → explain how to fix it
|
|
207
|
+
if (!ctx) throw new Error('useAbCarousel() must be used inside <AbCarousel>.');
|
|
208
|
+
// 📦
|
|
209
|
+
return ctx;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ===== INTERNAL HELPERS ===== //
|
|
213
|
+
|
|
214
|
+
// 🧩 expand fragments + arrays into a flat list of valid elements
|
|
215
|
+
function flattenAbSlides(children: ReactNode): ReactElement[] {
|
|
216
|
+
// 📦 result
|
|
217
|
+
const out: ReactElement[] = [];
|
|
218
|
+
// 🔁 Children.toArray flattens arrays and drops null / booleans
|
|
219
|
+
for (const child of Children.toArray(children)) {
|
|
220
|
+
if (!isValidElement(child)) continue; // 🙅 text nodes are not slides
|
|
221
|
+
// 🧩 <>…</> → look inside
|
|
222
|
+
if (child.type === Fragment) out.push(...flattenAbSlides((child.props as { children?: ReactNode }).children));
|
|
223
|
+
else out.push(child); // ✅ a slide
|
|
224
|
+
}
|
|
225
|
+
return out; // 📦
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 🔍 find <AbCarouselTrack> in the element tree and count its slides (null when not visible from here)
|
|
229
|
+
function countAbCarouselSlides(children: ReactNode): number | null {
|
|
230
|
+
// 🔁 breadth-first over the visible element tree
|
|
231
|
+
for (const child of Children.toArray(children)) {
|
|
232
|
+
if (!isValidElement(child)) continue; // 🙅
|
|
233
|
+
const props = child.props as { children?: ReactNode }; // 🧩
|
|
234
|
+
if (child.type === AbCarouselTrack) return flattenAbSlides(props.children).length; // ✅ found it
|
|
235
|
+
const nested = countAbCarouselSlides(props.children); // 🔎 dig (layout wrappers like <main>, <div>)
|
|
236
|
+
if (nested !== null) return nested; // ✅
|
|
237
|
+
}
|
|
238
|
+
return null; // 🤷 hidden inside a custom component → pass `count`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 🟢 hydration flag without setState-in-effect: false on the server + hydration pass, true afterwards
|
|
242
|
+
const subscribeNothing = () => () => {};
|
|
243
|
+
const useAbHydrated = (): boolean => useSyncExternalStore(subscribeNothing, () => true, () => false);
|
|
244
|
+
|
|
245
|
+
// 🌍 live <html dir> (server: 'ltr')
|
|
246
|
+
function subscribeAbDocumentDir(onChange: () => void): () => void {
|
|
247
|
+
// 🚫 no DOM / observer
|
|
248
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
|
249
|
+
// 👀 watch the dir attribute
|
|
250
|
+
const observer = new MutationObserver(onChange);
|
|
251
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] });
|
|
252
|
+
// 🧹
|
|
253
|
+
return () => observer.disconnect();
|
|
254
|
+
}
|
|
255
|
+
const readAbDocumentDir = (): AbCarouselDir => (document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr');
|
|
256
|
+
const readAbServerDir = (): AbCarouselDir => 'ltr';
|
|
257
|
+
|
|
258
|
+
// ===== <AbCarousel /> ===== //
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 🎠 AbCarousel — accessible slide carousel / walkthrough (Welcome v6, gap G31).
|
|
262
|
+
*
|
|
263
|
+
* ♿ a11y (WAI-ARIA APG carousel pattern):
|
|
264
|
+
* - region `aria-roledescription="carousel"` + `aria-label`
|
|
265
|
+
* - each slide `role="group"` + `aria-roledescription="slide"` + `aria-label="3 of 9"`;
|
|
266
|
+
* inactive slides are `inert` + `aria-hidden` once hydrated (SSR / no-JS keeps them readable)
|
|
267
|
+
* - polite live region announces "Slide 3 of 9: Title" on change (never on first load)
|
|
268
|
+
* - ←/→ (RTL-aware) + Home/End only while focus is inside, and never inside tabs / inputs / radio groups
|
|
269
|
+
* - swipe is optional sugar: buttons + dots always exist
|
|
270
|
+
* - no autoplay, ever
|
|
271
|
+
*
|
|
272
|
+
* 🎬 Motion: slides cross-fade + shift 24px (280ms); reduced motion → 120ms fade, no transform.
|
|
273
|
+
*/
|
|
274
|
+
export function AbCarousel(props: AbCarouselProps) {
|
|
275
|
+
// 🧩 destructure with defaults
|
|
276
|
+
const {
|
|
277
|
+
label, // 🏷️
|
|
278
|
+
index: indexProp, // 🎛️
|
|
279
|
+
defaultIndex = 0, // 🏁
|
|
280
|
+
onIndexChange, // 📣
|
|
281
|
+
count: countProp, // 🔢
|
|
282
|
+
titles = [], // 📰
|
|
283
|
+
loop = false, // 🔁
|
|
284
|
+
dir: dirProp, // 🌍
|
|
285
|
+
keyboard = true, // ⌨️
|
|
286
|
+
swipe = true, // 👆
|
|
287
|
+
swipeThreshold, // 📏
|
|
288
|
+
swipeIgnore, // 🙈
|
|
289
|
+
reducedMotion: reducedMotionMode = 'auto', // ♿
|
|
290
|
+
slideLabel = defaultAbCarouselSlideLabel, // 🏷️
|
|
291
|
+
announce = defaultAbCarouselAnnouncement, // 📣
|
|
292
|
+
as: Tag = 'section', // 🧱
|
|
293
|
+
id, // 🆔
|
|
294
|
+
className, // 🏷️
|
|
295
|
+
style, // 🎨
|
|
296
|
+
ref, // 🔗
|
|
297
|
+
children, // 🧩
|
|
298
|
+
} = props;
|
|
299
|
+
|
|
300
|
+
// 🔢 slide count: explicit, else counted from the track
|
|
301
|
+
const count = Math.max(0, countProp ?? countAbCarouselSlides(children) ?? 0);
|
|
302
|
+
|
|
303
|
+
// 🎛️ controlled?
|
|
304
|
+
const isControlled = indexProp !== undefined;
|
|
305
|
+
// 📦 uncontrolled state (clamped once)
|
|
306
|
+
const [innerIndex, setInnerIndex] = useState(() => clampAbCarouselIndex(defaultIndex, count));
|
|
307
|
+
// 📍 active index (always clamped for rendering)
|
|
308
|
+
const index = clampAbCarouselIndex(isControlled ? (indexProp as number) : innerIndex, count);
|
|
309
|
+
|
|
310
|
+
// 🌍 reading direction: prop wins, else live <html dir>
|
|
311
|
+
const documentDir = useSyncExternalStore(subscribeAbDocumentDir, readAbDocumentDir, readAbServerDir);
|
|
312
|
+
const dir: AbCarouselDir = dirProp ?? documentDir;
|
|
313
|
+
|
|
314
|
+
// ♿ reduced motion
|
|
315
|
+
const osReduced = useAbReducedMotion();
|
|
316
|
+
const reducedMotion = reducedMotionMode === 'always' || (reducedMotionMode === 'auto' && osReduced);
|
|
317
|
+
|
|
318
|
+
// 🟢 hydrated?
|
|
319
|
+
const hydrated = useAbHydrated();
|
|
320
|
+
|
|
321
|
+
// 🆔 stable ids
|
|
322
|
+
const baseId = useId();
|
|
323
|
+
const trackId = `${baseId}-track`;
|
|
324
|
+
const slideId = useCallback((i: number) => `${baseId}-slide-${i}`, [baseId]);
|
|
325
|
+
|
|
326
|
+
// 📣 live region: announce only after the index changed at least once ("adjust state while rendering")
|
|
327
|
+
const [lastIndex, setLastIndex] = useState(index);
|
|
328
|
+
const [announcing, setAnnouncing] = useState(false);
|
|
329
|
+
// 🧭 direction + previous slide for the ab-nextjs-animations slide step
|
|
330
|
+
const [direction, setDirection] = useState<AbSlideStepDirection | null>(null);
|
|
331
|
+
const [previousIndex, setPreviousIndex] = useState(-1);
|
|
332
|
+
if (lastIndex !== index) {
|
|
333
|
+
setPreviousIndex(lastIndex); // ⏮️ this one leaves
|
|
334
|
+
setDirection(getAbSlideStepDirection(lastIndex, index, count, loop)); // 🧭 shortest way round when looping
|
|
335
|
+
setLastIndex(index); // 📍 remember
|
|
336
|
+
setAnnouncing(true); // 📣 from now on, speak
|
|
337
|
+
}
|
|
338
|
+
const message = announcing && announce ? announce(index + 1, count, titles[index]) : '';
|
|
339
|
+
|
|
340
|
+
// 🎯 change the index (clamped / wrapped); no-op when unchanged
|
|
341
|
+
const goTo = useCallback((target: number, reason: AbCarouselChangeReason = 'api') => {
|
|
342
|
+
// 🔒 resolve the target
|
|
343
|
+
const nextIndex = clampAbCarouselIndex(target, count, loop);
|
|
344
|
+
// 🙅 nothing to do
|
|
345
|
+
if (nextIndex === index || count === 0) return;
|
|
346
|
+
// 📦 uncontrolled → own the state
|
|
347
|
+
if (!isControlled) setInnerIndex(nextIndex);
|
|
348
|
+
// 📣 tell the parent (controlled callers update `index` here)
|
|
349
|
+
onIndexChange?.(nextIndex, { reason, from: index });
|
|
350
|
+
}, [count, loop, index, isControlled, onIndexChange]);
|
|
351
|
+
|
|
352
|
+
// ⏭️ / ⏮️
|
|
353
|
+
const next = useCallback((reason: AbCarouselChangeReason = 'next') => goTo(index + 1, reason), [goTo, index]);
|
|
354
|
+
const prev = useCallback((reason: AbCarouselChangeReason = 'prev') => goTo(index - 1, reason), [goTo, index]);
|
|
355
|
+
// 🚦 edges
|
|
356
|
+
const canPrev = count > 1 && (loop || index > 0);
|
|
357
|
+
const canNext = count > 1 && (loop || index < count - 1);
|
|
358
|
+
|
|
359
|
+
// ⌨️ keyboard (only while focus is inside the region: the handler lives on the region)
|
|
360
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
361
|
+
if (!keyboard || event.defaultPrevented) return; // 🔌 off / handled already
|
|
362
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; // 🙅 shortcuts belong to the browser
|
|
363
|
+
const target = event.target as HTMLElement | null; // 🎯
|
|
364
|
+
if (target?.closest?.(AB_CAROUSEL_KEY_IGNORE_SELECTOR)) return; // 🙈 tabs / inputs own their arrows
|
|
365
|
+
const action = getAbCarouselKeyAction(event.key, dir); // 🗺️
|
|
366
|
+
if (!action) return; // 🙅
|
|
367
|
+
event.preventDefault(); // 🛑 no page scroll on Home/End
|
|
368
|
+
if (action === 'next') next('key'); // ⏭️
|
|
369
|
+
else if (action === 'prev') prev('key'); // ⏮️
|
|
370
|
+
else if (action === 'first') goTo(0, 'key'); // ⏮️⏮️
|
|
371
|
+
else goTo(count - 1, 'key'); // ⏭️⏭️
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// 🧠 context value (memoised)
|
|
375
|
+
const value = useMemo<AbCarouselContextValue>(() => ({
|
|
376
|
+
index, count, loop, dir, reducedMotion, hydrated, direction, previousIndex, goTo, next, prev, canPrev, canNext, titles, slideLabel, trackId, slideId,
|
|
377
|
+
swipe: { enabled: swipe && count > 1, threshold: swipeThreshold, ignore: swipeIgnore },
|
|
378
|
+
}), [index, count, loop, dir, reducedMotion, hydrated, direction, previousIndex, goTo, next, prev, canPrev, canNext, titles, slideLabel, trackId, slideId, swipe, swipeThreshold, swipeIgnore]);
|
|
379
|
+
|
|
380
|
+
// 🖼️ render
|
|
381
|
+
return (
|
|
382
|
+
<AbCarouselContext.Provider value={value}>
|
|
383
|
+
<Tag
|
|
384
|
+
ref={ref as Ref<HTMLElement & HTMLDivElement>} // 🔗
|
|
385
|
+
id={id} // 🆔
|
|
386
|
+
className={clsx('AbCarousel', styles.carousel, className)} // 🏷️
|
|
387
|
+
style={style} // 🎨
|
|
388
|
+
aria-roledescription="carousel" // ♿ "carousel" instead of "region"
|
|
389
|
+
aria-label={label} // 🏷️ accessible name
|
|
390
|
+
data-ab-part="carousel" // 🧩 part hook
|
|
391
|
+
data-dir={dir} // 🌍
|
|
392
|
+
data-reduced-motion={String(reducedMotion)} // ♿ CSS switch
|
|
393
|
+
data-ab-reduced-motion={String(reducedMotion)} // ♿ ab-nextjs-animations switch (prop OR OS)
|
|
394
|
+
data-ab-direction={direction ?? undefined} // 🧭 forward / back for .ab-slide-step-*
|
|
395
|
+
data-hydrated={String(hydrated)} // 🟢
|
|
396
|
+
onKeyDown={onKeyDown} // ⌨️
|
|
397
|
+
>
|
|
398
|
+
{children}
|
|
399
|
+
{/* 📣 polite live region (visually hidden) */}
|
|
400
|
+
<div className={styles.srOnly} aria-live="polite" aria-atomic="true" data-ab-part="status">{message}</div>
|
|
401
|
+
</Tag>
|
|
402
|
+
</AbCarouselContext.Provider>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ===== <AbCarouselTrack /> ===== //
|
|
407
|
+
|
|
408
|
+
// ⚙️ track props
|
|
409
|
+
export interface AbCarouselTrackProps {
|
|
410
|
+
// 🏷️ extra classes
|
|
411
|
+
className?: string;
|
|
412
|
+
// 🎨 inline style
|
|
413
|
+
style?: CSSProperties;
|
|
414
|
+
// 🎞️ <AbCarouselSlide> children (fragments / arrays are fine)
|
|
415
|
+
children?: ReactNode;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 🎞️ AbCarouselTrack — stacks the slides in one grid cell (height = tallest slide → no layout shift)
|
|
420
|
+
* and owns the swipe gesture (`touch-action: pan-y` keeps vertical scrolling).
|
|
421
|
+
*/
|
|
422
|
+
export function AbCarouselTrack({ className, style, children }: AbCarouselTrackProps) {
|
|
423
|
+
// 🧠 carousel state
|
|
424
|
+
const ctx = useAbCarousel();
|
|
425
|
+
// 🎯 element for the swipe hook
|
|
426
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
427
|
+
// 👆 swipe → next / prev (RTL-aware via dir)
|
|
428
|
+
useAbSwipe(trackRef, {
|
|
429
|
+
onNext: () => ctx.next('swipe'), // ⏭️
|
|
430
|
+
onPrev: () => ctx.prev('swipe'), // ⏮️
|
|
431
|
+
enabled: ctx.swipe.enabled, // 🔌
|
|
432
|
+
threshold: ctx.swipe.threshold, // 📏
|
|
433
|
+
ignore: ctx.swipe.ignore, // 🙈
|
|
434
|
+
dir: ctx.dir, // 🌍
|
|
435
|
+
});
|
|
436
|
+
// 🎞️ flat slide list
|
|
437
|
+
const slides = flattenAbSlides(children);
|
|
438
|
+
// 🖼️ render, giving each slide its index
|
|
439
|
+
return (
|
|
440
|
+
<div ref={trackRef} id={ctx.trackId} className={clsx(styles.track, className)} style={style} data-ab-part="track">
|
|
441
|
+
{slides.map((slide, i) => (
|
|
442
|
+
// 🔢 index provider per slide (keys come from the consumer, else the position)
|
|
443
|
+
<AbCarouselSlideIndexContext.Provider key={slide.key ?? i} value={i}>{slide}</AbCarouselSlideIndexContext.Provider>
|
|
444
|
+
))}
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ===== <AbCarouselSlide /> ===== //
|
|
450
|
+
|
|
451
|
+
// ⚙️ slide props
|
|
452
|
+
export interface AbCarouselSlideProps {
|
|
453
|
+
// 🏷️ extra classes
|
|
454
|
+
className?: string;
|
|
455
|
+
// 🎨 inline style
|
|
456
|
+
style?: CSSProperties;
|
|
457
|
+
// 🏷️ override the default "3 of 9" label
|
|
458
|
+
label?: string;
|
|
459
|
+
// 🧩 slide content (text pane, illustration…)
|
|
460
|
+
children?: ReactNode;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 🖼️ AbCarouselSlide — one slide: `role="group"` + `aria-roledescription="slide"` + "n of total".
|
|
465
|
+
* Inactive slides are hidden visually (opacity / visibility) and, after hydration, `inert` + `aria-hidden`.
|
|
466
|
+
*/
|
|
467
|
+
export function AbCarouselSlide({ className, style, label, children }: AbCarouselSlideProps) {
|
|
468
|
+
// 🧠 carousel + my index
|
|
469
|
+
const ctx = useAbCarousel();
|
|
470
|
+
const i = useContext(AbCarouselSlideIndexContext);
|
|
471
|
+
// 📍 position relative to the active slide
|
|
472
|
+
const position = getAbCarouselSlidePosition(i, ctx.index);
|
|
473
|
+
const active = position === 'active';
|
|
474
|
+
// 🙈 hide inactive slides from AT + focus only once JS runs (no-JS stack stays usable)
|
|
475
|
+
const hide = ctx.hydrated && !active;
|
|
476
|
+
// 🎬 slide step (only after a real move, so nothing animates on first paint)
|
|
477
|
+
const step = ctx.direction === null ? undefined : active ? AB_SLIDE_STEP_IN_CLASS : i === ctx.previousIndex ? AB_SLIDE_STEP_OUT_CLASS : undefined;
|
|
478
|
+
// 🖼️ render
|
|
479
|
+
return (
|
|
480
|
+
<div
|
|
481
|
+
id={ctx.slideId(i)} // 🆔 aria-controls target for dots
|
|
482
|
+
role="group" // ♿
|
|
483
|
+
aria-roledescription="slide" // ♿
|
|
484
|
+
aria-label={label ?? ctx.slideLabel(i + 1, ctx.count)} // 🏷️ "3 of 9"
|
|
485
|
+
aria-hidden={hide || undefined} // 🙈
|
|
486
|
+
inert={hide || undefined} // 🚫 no focus / clicks while hidden
|
|
487
|
+
className={clsx(styles.slide, step, className)} // 🏷️ + 🎬 enter / leave
|
|
488
|
+
style={style} // 🎨
|
|
489
|
+
data-ab-part="slide" // 🧩
|
|
490
|
+
data-position={position} // 📍 CSS offset
|
|
491
|
+
data-active={String(active)} // ✅
|
|
492
|
+
>
|
|
493
|
+
{children}
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ===== <AbCarouselPrev /> + <AbCarouselNext /> ===== //
|
|
499
|
+
|
|
500
|
+
// ⚙️ prev / next button props
|
|
501
|
+
export interface AbCarouselNavProps {
|
|
502
|
+
// 🏷️ accessible name (default "Previous slide" / "Next slide"), also the tooltip
|
|
503
|
+
label?: string;
|
|
504
|
+
// 🎨 'outlined' (default for prev) or 'filled' (default for next, primary bronze)
|
|
505
|
+
variant?: 'outlined' | 'filled';
|
|
506
|
+
// 🙈 render nothing at the edge instead of an aria-disabled button (Welcome s09: no arrows)
|
|
507
|
+
hideAtEdge?: boolean;
|
|
508
|
+
// 🏷️ extra classes
|
|
509
|
+
className?: string;
|
|
510
|
+
// 🎨 inline style
|
|
511
|
+
style?: CSSProperties;
|
|
512
|
+
// 🧩 custom icon / content (default: AbIcons arrow, RTL-mirrored)
|
|
513
|
+
children?: ReactNode;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ➡️ AbIcons arrow (G2): weight 300 = stroke 1.5, mirrored in RTL via .navIcon
|
|
517
|
+
function AbCarouselArrow({ kind }: { kind: 'prev' | 'next' }) {
|
|
518
|
+
return <AbIcon className={styles.navIcon} name={kind === 'prev' ? 'arrow_back' : 'arrow_forward'} size={20} weight={300} />; // 🔣 decorative (button is named)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 🔘 shared prev / next implementation
|
|
522
|
+
function AbCarouselNavButton({ kind, label, variant, hideAtEdge = false, className, style, children }: AbCarouselNavProps & { kind: 'prev' | 'next' }) {
|
|
523
|
+
// 🧠 carousel
|
|
524
|
+
const ctx = useAbCarousel();
|
|
525
|
+
// 🚦 can we move?
|
|
526
|
+
const enabled = kind === 'prev' ? ctx.canPrev : ctx.canNext;
|
|
527
|
+
// 🙈 Welcome s09: no arrows at all
|
|
528
|
+
if (hideAtEdge && !enabled) return null;
|
|
529
|
+
// 🏷️ name
|
|
530
|
+
const name = label ?? (kind === 'prev' ? 'Previous slide' : 'Next slide');
|
|
531
|
+
// 🎨 variant
|
|
532
|
+
const look = variant ?? (kind === 'next' ? 'filled' : 'outlined');
|
|
533
|
+
// 🖼️ aria-disabled (not `disabled`) keeps focus on the button at the edge
|
|
534
|
+
return (
|
|
535
|
+
<button
|
|
536
|
+
type="button" // 🛑 never submits a form
|
|
537
|
+
className={clsx(styles.nav, AB_MOTION.interactive, AB_MOTION.press, className)} // 🏷️ + 🎨 hover / 🤏 press
|
|
538
|
+
style={style} // 🎨
|
|
539
|
+
aria-label={name} // ♿ icon-only → name it
|
|
540
|
+
title={name} // 💬 tooltip
|
|
541
|
+
aria-controls={ctx.trackId} // 🔗
|
|
542
|
+
aria-disabled={!enabled || undefined} // 🚦
|
|
543
|
+
data-ab-part={kind} // 🧩
|
|
544
|
+
data-variant={look} // 🎨
|
|
545
|
+
onClick={() => {
|
|
546
|
+
if (!enabled) return; // 🚦 edge: stay put (focus stays on the button)
|
|
547
|
+
if (kind === 'prev') ctx.prev('prev'); // ⏮️
|
|
548
|
+
else ctx.next('next'); // ⏭️
|
|
549
|
+
}}
|
|
550
|
+
>
|
|
551
|
+
{children ?? <AbCarouselArrow kind={kind} />}
|
|
552
|
+
</button>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ⏮️ previous-slide button
|
|
557
|
+
export function AbCarouselPrev(props: AbCarouselNavProps) {
|
|
558
|
+
return <AbCarouselNavButton kind="prev" {...props} />;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ⏭️ next-slide button
|
|
562
|
+
export function AbCarouselNext(props: AbCarouselNavProps) {
|
|
563
|
+
return <AbCarouselNavButton kind="next" {...props} />;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ===== <AbCarouselDots /> ===== //
|
|
567
|
+
|
|
568
|
+
// ⚙️ dots props
|
|
569
|
+
export interface AbCarouselDotsProps {
|
|
570
|
+
// 🏷️ group name (default "Choose slide")
|
|
571
|
+
label?: string;
|
|
572
|
+
// 🏷️ per-dot name (default "Go to slide 3: Title")
|
|
573
|
+
dotLabel?: (n: number, total: number, title?: string) => string;
|
|
574
|
+
// 🏷️ extra classes
|
|
575
|
+
className?: string;
|
|
576
|
+
// 🎨 inline style
|
|
577
|
+
style?: CSSProperties;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 🔘 AbCarouselDots — a `role="group"` of real buttons (not a tablist: slides aren't tab panels).
|
|
582
|
+
* Active dot: `aria-current="step"` + long pill. Each button is a 24×24 target (WCAG 2.5.8).
|
|
583
|
+
*/
|
|
584
|
+
export function AbCarouselDots({ label = 'Choose slide', dotLabel = defaultAbCarouselDotLabel, className, style }: AbCarouselDotsProps) {
|
|
585
|
+
// 🧠 carousel
|
|
586
|
+
const ctx = useAbCarousel();
|
|
587
|
+
// 🖼️ render
|
|
588
|
+
return (
|
|
589
|
+
<div role="group" aria-label={label} className={clsx(styles.dots, className)} style={style} data-ab-part="dots">
|
|
590
|
+
{Array.from({ length: ctx.count }, (_, i) => {
|
|
591
|
+
const active = i === ctx.index; // ✅
|
|
592
|
+
return (
|
|
593
|
+
<button
|
|
594
|
+
key={i} // 🔑
|
|
595
|
+
type="button" // 🛑
|
|
596
|
+
className={styles.dot} // 🏷️
|
|
597
|
+
aria-label={dotLabel(i + 1, ctx.count, ctx.titles[i])} // ♿
|
|
598
|
+
aria-current={active ? 'step' : undefined} // 📍
|
|
599
|
+
aria-controls={ctx.slideId(i)} // 🔗
|
|
600
|
+
data-ab-part="dot" // 🧩
|
|
601
|
+
data-active={String(active)} // 🎨
|
|
602
|
+
onClick={() => ctx.goTo(i, 'dot')} // 🎯 focus stays on the dot
|
|
603
|
+
>
|
|
604
|
+
{/* 🎨 the visible 8px dot / 24px pill */}
|
|
605
|
+
<span className={clsx(styles.dotMark, AB_MOTION.size)} aria-hidden="true" /> {/* 📏 8 → 24px via ab-motion-size */}
|
|
606
|
+
</button>
|
|
607
|
+
);
|
|
608
|
+
})}
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ===== <AbCarouselCounter /> ===== //
|
|
614
|
+
|
|
615
|
+
// ⚙️ counter props
|
|
616
|
+
export interface AbCarouselCounterProps {
|
|
617
|
+
// 🧮 custom formatter (default "<b>02</b> / 09")
|
|
618
|
+
format?: (current: number, total: number) => ReactNode;
|
|
619
|
+
// 🏷️ extra classes
|
|
620
|
+
className?: string;
|
|
621
|
+
// 🎨 inline style
|
|
622
|
+
style?: CSSProperties;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 🔢 AbCarouselCounter — "02 / 09" (mono, current in bold).
|
|
627
|
+
* Hidden from AT by default: each slide's "2 of 9" label + the live region already say it.
|
|
628
|
+
*/
|
|
629
|
+
export function AbCarouselCounter({ format, className, style }: AbCarouselCounterProps) {
|
|
630
|
+
// 🧠 carousel
|
|
631
|
+
const ctx = useAbCarousel();
|
|
632
|
+
// 🔢 1-based current
|
|
633
|
+
const current = ctx.count === 0 ? 0 : ctx.index + 1;
|
|
634
|
+
// 🖼️ render
|
|
635
|
+
return (
|
|
636
|
+
<span className={clsx(styles.counter, className)} style={style} data-ab-part="counter" aria-hidden="true">
|
|
637
|
+
{format ? format(current, ctx.count) : (<><b>{padAbCarouselNumber(current)}</b>{' / '}{padAbCarouselNumber(ctx.count)}</>)}
|
|
638
|
+
</span>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 📦 default export = the root
|
|
643
|
+
export default AbCarousel;
|