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.
Files changed (59) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/ab-brand/index.tsx +30 -1
  3. package/ab-brand/styles.module.css +40 -1
  4. package/ab-button/helpers.ts +90 -0
  5. package/ab-button/index.tsx +164 -87
  6. package/ab-button/styles.module.css +79 -1
  7. package/ab-carousel/helpers.ts +146 -0
  8. package/ab-carousel/index.tsx +643 -0
  9. package/ab-carousel/styles.module.css +206 -0
  10. package/ab-chip/index.tsx +75 -0
  11. package/ab-chip/styles.module.css +87 -0
  12. package/ab-collapsible/index.tsx +30 -1
  13. package/ab-collapsible/styles.module.css +40 -1
  14. package/ab-flow-diagram/helpers.ts +77 -0
  15. package/ab-flow-diagram/index.tsx +141 -0
  16. package/ab-flow-diagram/styles.module.css +141 -0
  17. package/ab-icon/builtin.ts +61 -0
  18. package/ab-icon/helpers.ts +102 -0
  19. package/ab-icon/index.tsx +77 -16
  20. package/ab-icon/styles.module.css +53 -9
  21. package/ab-icon-button/index.tsx +110 -46
  22. package/ab-icon-button/styles.module.css +28 -10
  23. package/ab-install-snippet/helpers.ts +62 -0
  24. package/ab-install-snippet/index.tsx +170 -0
  25. package/ab-install-snippet/styles.module.css +104 -0
  26. package/ab-like-button/index.tsx +33 -1
  27. package/ab-like-button/styles.module.css +41 -6
  28. package/ab-linear-progress/index.tsx +129 -0
  29. package/ab-linear-progress/styles.module.css +105 -0
  30. package/ab-locale-switcher/index.tsx +213 -0
  31. package/ab-locale-switcher/styles.module.css +127 -0
  32. package/ab-logo/index.tsx +88 -30
  33. package/ab-logo/styles.module.css +79 -11
  34. package/ab-pill/index.tsx +74 -0
  35. package/ab-progress-chips/index.tsx +30 -1
  36. package/ab-progress-chips/styles.module.css +40 -1
  37. package/ab-searchbar/index.tsx +33 -2
  38. package/ab-searchbar/styles.module.css +41 -5
  39. package/ab-splash/csp.d.ts +54 -0
  40. package/ab-splash/csp.mjs +82 -0
  41. package/ab-splash/csp.ts +60 -0
  42. package/ab-splash/gate.ts +216 -0
  43. package/ab-splash/index.tsx +140 -0
  44. package/ab-splash/styles.module.css +103 -0
  45. package/ab-theme-toggle/index.tsx +188 -0
  46. package/ab-theme-toggle/styles.module.css +89 -0
  47. package/css/dialog.css +38 -3
  48. package/index.ts +47 -0
  49. package/package.json +46 -18
  50. package/server/ab-brand/index.tsx +29 -1
  51. package/server/ab-brand/styles.module.css +40 -1
  52. package/server/ab-button/index.tsx +96 -79
  53. package/server/ab-button/styles.module.css +79 -1
  54. package/server/ab-icon/index.tsx +68 -16
  55. package/server/ab-icon/styles.module.css +53 -9
  56. package/server/ab-logo/index.tsx +88 -29
  57. package/server/ab-logo/styles.module.css +79 -11
  58. package/types/css.d.ts +43 -0
  59. 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;