@cdx-ui/components 0.0.1-beta.121 → 0.0.1-beta.122

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 (43) hide show
  1. package/lib/commonjs/components/ScrollShadow/index.js +82 -0
  2. package/lib/commonjs/components/ScrollShadow/index.js.map +1 -0
  3. package/lib/commonjs/components/ScrollShadow/styles.js +54 -0
  4. package/lib/commonjs/components/ScrollShadow/styles.js.map +1 -0
  5. package/lib/commonjs/components/ScrollShadow/useScrollShadow.js +83 -0
  6. package/lib/commonjs/components/ScrollShadow/useScrollShadow.js.map +1 -0
  7. package/lib/commonjs/components/Tabs/index.js +525 -0
  8. package/lib/commonjs/components/Tabs/index.js.map +1 -0
  9. package/lib/commonjs/components/Tabs/styles.js +333 -0
  10. package/lib/commonjs/components/Tabs/styles.js.map +1 -0
  11. package/lib/commonjs/components/index.js +24 -0
  12. package/lib/commonjs/components/index.js.map +1 -1
  13. package/lib/module/components/ScrollShadow/index.js +78 -0
  14. package/lib/module/components/ScrollShadow/index.js.map +1 -0
  15. package/lib/module/components/ScrollShadow/styles.js +50 -0
  16. package/lib/module/components/ScrollShadow/styles.js.map +1 -0
  17. package/lib/module/components/ScrollShadow/useScrollShadow.js +79 -0
  18. package/lib/module/components/ScrollShadow/useScrollShadow.js.map +1 -0
  19. package/lib/module/components/Tabs/index.js +520 -0
  20. package/lib/module/components/Tabs/index.js.map +1 -0
  21. package/lib/module/components/Tabs/styles.js +330 -0
  22. package/lib/module/components/Tabs/styles.js.map +1 -0
  23. package/lib/module/components/index.js +2 -0
  24. package/lib/module/components/index.js.map +1 -1
  25. package/lib/typescript/components/ScrollShadow/index.d.ts +29 -0
  26. package/lib/typescript/components/ScrollShadow/index.d.ts.map +1 -0
  27. package/lib/typescript/components/ScrollShadow/styles.d.ts +12 -0
  28. package/lib/typescript/components/ScrollShadow/styles.d.ts.map +1 -0
  29. package/lib/typescript/components/ScrollShadow/useScrollShadow.d.ts +17 -0
  30. package/lib/typescript/components/ScrollShadow/useScrollShadow.d.ts.map +1 -0
  31. package/lib/typescript/components/Tabs/index.d.ts +48 -0
  32. package/lib/typescript/components/Tabs/index.d.ts.map +1 -0
  33. package/lib/typescript/components/Tabs/styles.d.ts +107 -0
  34. package/lib/typescript/components/Tabs/styles.d.ts.map +1 -0
  35. package/lib/typescript/components/index.d.ts +2 -0
  36. package/lib/typescript/components/index.d.ts.map +1 -1
  37. package/package.json +4 -4
  38. package/src/components/ScrollShadow/index.tsx +119 -0
  39. package/src/components/ScrollShadow/styles.ts +57 -0
  40. package/src/components/ScrollShadow/useScrollShadow.ts +110 -0
  41. package/src/components/Tabs/index.tsx +694 -0
  42. package/src/components/Tabs/styles.ts +340 -0
  43. package/src/components/index.ts +2 -0
@@ -0,0 +1,694 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type ComponentProps,
9
+ type ComponentRef,
10
+ type ReactNode,
11
+ } from 'react';
12
+ import {
13
+ Platform,
14
+ Pressable,
15
+ ScrollView,
16
+ StyleSheet,
17
+ Text,
18
+ View,
19
+ type LayoutChangeEvent,
20
+ type TextProps,
21
+ type ViewProps,
22
+ } from 'react-native';
23
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
24
+ import { ChevronLeft, ChevronRight } from '@cdx-ui/icons';
25
+ import {
26
+ Tab as TabPrimitive,
27
+ TabPanel as TabPanelPrimitive,
28
+ TabsList as TabsListPrimitive,
29
+ TabsRoot as TabsRootPrimitive,
30
+ Slot,
31
+ dataAttributes,
32
+ mergeDataAttributes,
33
+ useTabContext,
34
+ useTabsContext,
35
+ type ITabPanelProps,
36
+ type ITabProps,
37
+ type ITabsListProps,
38
+ type ITabsProps,
39
+ } from '@cdx-ui/primitives';
40
+ import { cn, createContext } from '@cdx-ui/utils';
41
+ import { Icon } from '../Icon';
42
+ import { ScrollShadow } from '../ScrollShadow';
43
+ import {
44
+ tabsIndicatorVariants,
45
+ tabsListSolidShellBgVariants,
46
+ tabsListSolidShellVariants,
47
+ tabsListSolidScrollButtonSlotVariants,
48
+ tabsListSolidScrollButtonVariants,
49
+ tabsListScrollButtonVariants,
50
+ tabsListScrollContainerVariants,
51
+ tabsListScrollViewVariants,
52
+ tabsListTrackVariants,
53
+ tabsListVariants,
54
+ tabsOutlineTrackVariants,
55
+ tabsPanelVariants,
56
+ tabsRootVariants,
57
+ tabsStyles,
58
+ tabsTabLabelVariants,
59
+ tabsTabVariants,
60
+ TABS_INDICATOR_TRANSITION_MS,
61
+ TABS_SOLID_SCROLL_FADE_SIZE,
62
+ TABS_SCROLL_FADE_SIZE,
63
+ OUTLINE_INDICATOR_THICKNESS,
64
+ type TabsVariant,
65
+ } from './styles';
66
+
67
+ export type { TabsVariant };
68
+
69
+ interface TabLayout {
70
+ x: number;
71
+ y: number;
72
+ width: number;
73
+ height: number;
74
+ }
75
+
76
+ /** Minimum horizontal scroll offset so `layout` is fully inside the viewport. */
77
+ function scrollXToRevealTab(
78
+ layout: TabLayout,
79
+ viewportWidth: number,
80
+ currentScrollX: number,
81
+ ): number {
82
+ const tabStart = layout.x;
83
+ const tabEnd = layout.x + layout.width;
84
+ const viewEnd = currentScrollX + viewportWidth;
85
+
86
+ if (tabStart < currentScrollX) {
87
+ return tabStart;
88
+ }
89
+ if (tabEnd > viewEnd) {
90
+ return tabEnd - viewportWidth;
91
+ }
92
+ return currentScrollX;
93
+ }
94
+
95
+ function clampScrollX(scrollX: number, viewportWidth: number, contentWidth: number): number {
96
+ const maxScrollX = Math.max(0, contentWidth - viewportWidth);
97
+ return Math.min(Math.max(0, scrollX), maxScrollX);
98
+ }
99
+
100
+ interface TabsIndicatorContextValue {
101
+ reportLayout: (value: string, layout: TabLayout) => void;
102
+ clearLayout: (value: string) => void;
103
+ variant: TabsVariant;
104
+ }
105
+
106
+ const [TabsIndicatorProvider, useTabsIndicatorContext] =
107
+ createContext<TabsIndicatorContextValue>('TabsIndicatorContext');
108
+
109
+ interface TabsVariantContextValue {
110
+ variant: TabsVariant;
111
+ }
112
+
113
+ const [TabsVariantProvider, useTabsVariantContext] =
114
+ createContext<TabsVariantContextValue>('TabsVariantContext');
115
+
116
+ // =============================================================================
117
+ // STYLED ROOT
118
+ // =============================================================================
119
+
120
+ export interface TabsProps extends Omit<ViewProps, 'children'>, ITabsProps {
121
+ /** `solid` (default) slides a filled indicator behind the active tab; `outline` draws a sliding line indicator. Styled per Forge DS SegmentedControl (solid). */
122
+ variant?: TabsVariant;
123
+ className?: string;
124
+ }
125
+
126
+ const TabsRoot = forwardRef<ComponentRef<typeof View>, TabsProps>(
127
+ (
128
+ { className, style, children, variant = 'solid', orientation = 'horizontal', ...props },
129
+ ref,
130
+ ) => (
131
+ <TabsVariantProvider value={{ variant }}>
132
+ <TabsRootPrimitive
133
+ ref={ref}
134
+ orientation={orientation}
135
+ className={cn(tabsRootVariants({ orientation }), className)}
136
+ style={style}
137
+ {...props}
138
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'tabs' }))}
139
+ >
140
+ {children}
141
+ </TabsRootPrimitive>
142
+ </TabsVariantProvider>
143
+ ),
144
+ );
145
+
146
+ TabsRoot.displayName = 'Tabs';
147
+
148
+ // =============================================================================
149
+ // STYLED LIST — hosts the animated selection indicator
150
+ // =============================================================================
151
+
152
+ export interface TabsListProps extends ITabsListProps {
153
+ asChild?: boolean;
154
+ className?: string;
155
+ /**
156
+ * When `orientation="horizontal"`, scroll tab triggers if they overflow the list width.
157
+ * Vertical lists do not scroll — tabs stack in a column (matches HeroUI / ARIA scrollable
158
+ * tablist patterns, which are horizontal-only).
159
+ * @default true
160
+ */
161
+ scrollable?: boolean;
162
+ showsHorizontalScrollIndicator?: boolean;
163
+ }
164
+
165
+ const TabsList = forwardRef<ComponentRef<typeof View>, TabsListProps>(
166
+ (
167
+ {
168
+ asChild,
169
+ className,
170
+ style,
171
+ children,
172
+ scrollable = true,
173
+ showsHorizontalScrollIndicator = false,
174
+ ...props
175
+ },
176
+ ref,
177
+ ) => {
178
+ const { value, orientation, getTabs } = useTabsContext();
179
+ const { variant } = useTabsVariantContext();
180
+ const isHorizontalScroll = orientation === 'horizontal' && scrollable;
181
+ const isWebHorizontalScroll = isHorizontalScroll && Platform.OS === 'web';
182
+ const shouldAutoScrollActiveTab = isHorizontalScroll && Platform.OS !== 'web';
183
+
184
+ const layoutsRef = useRef<Map<string, TabLayout>>(new Map());
185
+ const hasMeasuredRef = useRef(false);
186
+ const scrollRef = useRef<ScrollView>(null);
187
+ const scrollViewWidthRef = useRef(0);
188
+ const scrollContentWidthRef = useRef(0);
189
+ const scrollXRef = useRef(0);
190
+ const [scrollCapability, setScrollCapability] = useState({
191
+ canScrollBefore: false,
192
+ canScrollAfter: false,
193
+ });
194
+ const translateX = useSharedValue(0);
195
+ const translateY = useSharedValue(0);
196
+ // Both variants animate measured width/height. Measured size + translate is reliable
197
+ // and keeps `rounded-full` looking like a capsule for the solid variant.
198
+ const width = useSharedValue(0);
199
+ const height = useSharedValue(0);
200
+
201
+ const scrollActiveTabIntoView = useCallback(
202
+ (layout: TabLayout | undefined, activeTabValue: string | undefined = value) => {
203
+ if (
204
+ !shouldAutoScrollActiveTab ||
205
+ !layout ||
206
+ !scrollRef.current ||
207
+ activeTabValue === undefined
208
+ ) {
209
+ return;
210
+ }
211
+
212
+ const viewportWidth = scrollViewWidthRef.current;
213
+ if (viewportWidth <= 0) {
214
+ return;
215
+ }
216
+
217
+ let nextScrollX = scrollXToRevealTab(layout, viewportWidth, scrollXRef.current);
218
+
219
+ const tabOrder = getTabs().map(([tabValue]) => tabValue);
220
+ const activeIndex = tabOrder.indexOf(activeTabValue);
221
+
222
+ // Active tab fully visible at the right edge — peek the next sibling in.
223
+ if (activeIndex >= 0 && activeIndex < tabOrder.length - 1) {
224
+ const nextLayout = layoutsRef.current.get(tabOrder[activeIndex + 1]);
225
+ if (nextLayout) {
226
+ const viewEnd = nextScrollX + viewportWidth;
227
+ const nextTabEnd = nextLayout.x + nextLayout.width;
228
+ if (nextTabEnd > viewEnd) {
229
+ nextScrollX = Math.max(nextScrollX, nextTabEnd - viewportWidth);
230
+ }
231
+ }
232
+ }
233
+
234
+ // Active tab fully visible at the left edge — peek the previous sibling in.
235
+ if (activeIndex > 0) {
236
+ const prevLayout = layoutsRef.current.get(tabOrder[activeIndex - 1]);
237
+ if (prevLayout && prevLayout.x < nextScrollX) {
238
+ nextScrollX = Math.min(nextScrollX, prevLayout.x);
239
+ }
240
+ }
241
+
242
+ nextScrollX = clampScrollX(nextScrollX, viewportWidth, scrollContentWidthRef.current);
243
+
244
+ scrollXRef.current = nextScrollX;
245
+ scrollRef.current.scrollTo({ x: nextScrollX, animated: true });
246
+ },
247
+ [getTabs, shouldAutoScrollActiveTab, value],
248
+ );
249
+
250
+ const retryScrollActiveTabIntoView = useCallback(() => {
251
+ if (value === undefined) {
252
+ return;
253
+ }
254
+
255
+ if (scrollViewWidthRef.current <= 0 || scrollContentWidthRef.current <= 0) {
256
+ return;
257
+ }
258
+
259
+ scrollActiveTabIntoView(layoutsRef.current.get(value), value);
260
+ }, [value, scrollActiveTabIntoView]);
261
+
262
+ const scrollTabsByPage = useCallback((direction: -1 | 1) => {
263
+ if (!scrollRef.current) {
264
+ return;
265
+ }
266
+
267
+ const viewportWidth = scrollViewWidthRef.current;
268
+ if (viewportWidth <= 0) {
269
+ return;
270
+ }
271
+
272
+ const nextScrollX = clampScrollX(
273
+ scrollXRef.current + direction * viewportWidth * 0.8,
274
+ viewportWidth,
275
+ scrollContentWidthRef.current,
276
+ );
277
+
278
+ scrollXRef.current = nextScrollX;
279
+ scrollRef.current.scrollTo({ x: nextScrollX, animated: true });
280
+ }, []);
281
+
282
+ const applyLayout = useCallback(
283
+ (layout: TabLayout | undefined) => {
284
+ if (!layout) {
285
+ return;
286
+ }
287
+
288
+ let nextX = layout.x;
289
+ let nextY = layout.y;
290
+ let nextW = layout.width;
291
+ let nextH = layout.height;
292
+
293
+ if (variant === 'outline') {
294
+ if (orientation === 'vertical') {
295
+ // X offset comes from `style.left = TRACK` (indicator to the right of the rail).
296
+ nextX = 0;
297
+ nextW = OUTLINE_INDICATOR_THICKNESS;
298
+ } else {
299
+ // Y offset comes from `style.bottom = TRACK` (indicator above the rail).
300
+ nextY = 0;
301
+ nextH = OUTLINE_INDICATOR_THICKNESS;
302
+ }
303
+ }
304
+
305
+ if (hasMeasuredRef.current) {
306
+ // Explicit duration — kept in sync with `TABS_INDICATOR_TRANSITION_MS`, the web focus
307
+ // ring's `transition` duration, so the ring doesn't visually outrun the sliding indicator.
308
+ const config = { duration: TABS_INDICATOR_TRANSITION_MS };
309
+ translateX.set(withTiming(nextX, config));
310
+ translateY.set(withTiming(nextY, config));
311
+ width.set(withTiming(nextW, config));
312
+ height.set(withTiming(nextH, config));
313
+ } else {
314
+ translateX.set(nextX);
315
+ translateY.set(nextY);
316
+ width.set(nextW);
317
+ height.set(nextH);
318
+ hasMeasuredRef.current = true;
319
+ }
320
+ },
321
+ [translateX, translateY, width, height, variant, orientation],
322
+ );
323
+
324
+ const reportLayout = useCallback(
325
+ (tabValue: string, layout: TabLayout) => {
326
+ layoutsRef.current.set(tabValue, layout);
327
+ if (tabValue === value) {
328
+ applyLayout(layout);
329
+ scrollActiveTabIntoView(layout, tabValue);
330
+ }
331
+ },
332
+ [value, applyLayout, scrollActiveTabIntoView],
333
+ );
334
+
335
+ const clearLayout = useCallback((tabValue: string) => {
336
+ layoutsRef.current.delete(tabValue);
337
+ }, []);
338
+
339
+ // `useLayoutEffect` (not `useEffect`) — the web focus ring's CSS transition starts
340
+ // synchronously as part of the same commit that flips `data-focus-visible` on the newly
341
+ // selected tab. A deferred `useEffect` would schedule the indicator's `withTiming` glide a
342
+ // frame later than that, so the ring's own transition would finish first even though both
343
+ // share `TABS_INDICATOR_TRANSITION_MS` — running this synchronously keeps both starting in
344
+ // the same frame.
345
+ useLayoutEffect(() => {
346
+ if (value !== undefined) {
347
+ const layout = layoutsRef.current.get(value);
348
+ applyLayout(layout);
349
+ scrollActiveTabIntoView(layout, value);
350
+ }
351
+ }, [value, applyLayout, scrollActiveTabIntoView]);
352
+
353
+ const indicatorAnimatedStyle = useAnimatedStyle(() => ({
354
+ width: width.get(),
355
+ height: height.get(),
356
+ transform: [{ translateX: translateX.get() }, { translateY: translateY.get() }],
357
+ }));
358
+
359
+ const isSolidHorizontalScroll = isHorizontalScroll && variant === 'solid';
360
+ const trackClassName = cn(
361
+ tabsListTrackVariants({ orientation, variant, scrollable: isHorizontalScroll }),
362
+ );
363
+ const trackStyle =
364
+ variant === 'solid' && !isSolidHorizontalScroll
365
+ ? orientation === 'vertical'
366
+ ? tabsStyles.solidTrackVertical
367
+ : tabsStyles.solidTrack
368
+ : null;
369
+
370
+ const handleScrollViewLayout = useCallback(
371
+ (event: LayoutChangeEvent) => {
372
+ scrollViewWidthRef.current = event.nativeEvent.layout.width;
373
+ retryScrollActiveTabIntoView();
374
+ },
375
+ [retryScrollActiveTabIntoView],
376
+ );
377
+
378
+ const handleScrollViewContentSizeChange = useCallback(
379
+ (contentWidth: number) => {
380
+ scrollContentWidthRef.current = contentWidth;
381
+ retryScrollActiveTabIntoView();
382
+ },
383
+ [retryScrollActiveTabIntoView],
384
+ );
385
+
386
+ const handleScrollViewScroll = useCallback(
387
+ (event: Parameters<NonNullable<ComponentProps<typeof ScrollView>['onScroll']>>[0]) => {
388
+ scrollXRef.current = event.nativeEvent.contentOffset.x;
389
+ },
390
+ [],
391
+ );
392
+
393
+ const listTrack = (
394
+ <View className={trackClassName} style={trackStyle}>
395
+ {variant === 'outline' ? (
396
+ <View pointerEvents="none" className={cn(tabsOutlineTrackVariants({ orientation }))} />
397
+ ) : null}
398
+ <Animated.View
399
+ pointerEvents="none"
400
+ className={cn(tabsIndicatorVariants({ orientation, variant }))}
401
+ style={[
402
+ variant === 'outline'
403
+ ? [
404
+ tabsStyles.outlineIndicator,
405
+ orientation === 'vertical'
406
+ ? tabsStyles.outlineIndicatorVertical
407
+ : tabsStyles.outlineIndicatorHorizontal,
408
+ ]
409
+ : tabsStyles.solidIndicator,
410
+ indicatorAnimatedStyle,
411
+ ]}
412
+ />
413
+ {children}
414
+ </View>
415
+ );
416
+
417
+ const horizontalScrollView = (
418
+ <ScrollView
419
+ ref={scrollRef}
420
+ horizontal
421
+ showsHorizontalScrollIndicator={showsHorizontalScrollIndicator}
422
+ keyboardShouldPersistTaps="handled"
423
+ className={cn(tabsListScrollViewVariants())}
424
+ style={isHorizontalScroll ? tabsStyles.horizontalScrollViewBleed : null}
425
+ contentContainerStyle={isHorizontalScroll ? tabsStyles.horizontalScrollContent : undefined}
426
+ onLayout={handleScrollViewLayout}
427
+ onContentSizeChange={handleScrollViewContentSizeChange}
428
+ onScroll={handleScrollViewScroll}
429
+ scrollEventThrottle={16}
430
+ >
431
+ {listTrack}
432
+ </ScrollView>
433
+ );
434
+
435
+ const wrapSolidScrollShell = (scrollArea: ReactNode) =>
436
+ isSolidHorizontalScroll ? (
437
+ <View className={cn(tabsListSolidShellVariants())} style={tabsStyles.solidShell}>
438
+ <View
439
+ pointerEvents="none"
440
+ className={cn(tabsListSolidShellBgVariants())}
441
+ style={tabsStyles.solidTrack}
442
+ />
443
+ <View className="relative z-10 min-w-0">{scrollArea}</View>
444
+ </View>
445
+ ) : (
446
+ scrollArea
447
+ );
448
+
449
+ const renderSolidScrollChevron = (position: 'start' | 'end', direction: -1 | 1) => {
450
+ const canScroll =
451
+ position === 'start' ? scrollCapability.canScrollBefore : scrollCapability.canScrollAfter;
452
+ if (!canScroll) {
453
+ return null;
454
+ }
455
+
456
+ const IconComponent = position === 'start' ? ChevronLeft : ChevronRight;
457
+ const label = position === 'start' ? 'Scroll tabs left' : 'Scroll tabs right';
458
+
459
+ return (
460
+ <View
461
+ pointerEvents="box-none"
462
+ className={cn(tabsListSolidScrollButtonSlotVariants({ position }))}
463
+ >
464
+ <Pressable
465
+ accessibilityLabel={label}
466
+ accessibilityRole="button"
467
+ className={cn(tabsListSolidScrollButtonVariants())}
468
+ tabIndex={-1}
469
+ onPress={() => scrollTabsByPage(direction)}
470
+ >
471
+ <Icon as={IconComponent} className="size-5 text-content-secondary" />
472
+ </Pressable>
473
+ </View>
474
+ );
475
+ };
476
+
477
+ const solidWebHorizontalScroll = (
478
+ <View className="relative min-w-0 w-full">
479
+ <ScrollShadow
480
+ hideScrollBar
481
+ orientation="horizontal"
482
+ size={TABS_SOLID_SCROLL_FADE_SIZE}
483
+ fadeFromClassName="from-surface-secondary"
484
+ onCapabilityChange={setScrollCapability}
485
+ >
486
+ {horizontalScrollView}
487
+ </ScrollShadow>
488
+ {renderSolidScrollChevron('start', -1)}
489
+ {renderSolidScrollChevron('end', 1)}
490
+ </View>
491
+ );
492
+
493
+ const outlineWebHorizontalScroll = (
494
+ <View className="w-full min-w-0 flex-row items-stretch">
495
+ {scrollCapability.canScrollBefore ? (
496
+ <Pressable
497
+ accessibilityLabel="Scroll tabs left"
498
+ accessibilityRole="button"
499
+ className={cn(tabsListScrollButtonVariants())}
500
+ tabIndex={-1}
501
+ onPress={() => scrollTabsByPage(-1)}
502
+ >
503
+ <Icon as={ChevronLeft} className="size-4 text-content-secondary" />
504
+ </Pressable>
505
+ ) : null}
506
+ <View className="min-w-0 flex-1">
507
+ <ScrollShadow
508
+ hideScrollBar
509
+ orientation="horizontal"
510
+ size={TABS_SCROLL_FADE_SIZE}
511
+ fadeFromClassName="from-background/85"
512
+ onCapabilityChange={setScrollCapability}
513
+ >
514
+ {horizontalScrollView}
515
+ </ScrollShadow>
516
+ </View>
517
+ {scrollCapability.canScrollAfter ? (
518
+ <Pressable
519
+ accessibilityLabel="Scroll tabs right"
520
+ accessibilityRole="button"
521
+ className={cn(tabsListScrollButtonVariants())}
522
+ tabIndex={-1}
523
+ onPress={() => scrollTabsByPage(1)}
524
+ >
525
+ <Icon as={ChevronRight} className="size-4 text-content-secondary" />
526
+ </Pressable>
527
+ ) : null}
528
+ </View>
529
+ );
530
+
531
+ return (
532
+ <TabsIndicatorProvider value={{ reportLayout, clearLayout, variant }}>
533
+ <TabsListPrimitive
534
+ ref={ref}
535
+ asChild={asChild}
536
+ className={cn(tabsListVariants(), className)}
537
+ style={style}
538
+ {...props}
539
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'tabs-list' }))}
540
+ >
541
+ {isWebHorizontalScroll ? (
542
+ <View className={cn(tabsListScrollContainerVariants())}>
543
+ {wrapSolidScrollShell(
544
+ isSolidHorizontalScroll ? solidWebHorizontalScroll : outlineWebHorizontalScroll,
545
+ )}
546
+ </View>
547
+ ) : isHorizontalScroll ? (
548
+ wrapSolidScrollShell(horizontalScrollView)
549
+ ) : (
550
+ listTrack
551
+ )}
552
+ </TabsListPrimitive>
553
+ </TabsIndicatorProvider>
554
+ );
555
+ },
556
+ );
557
+
558
+ TabsList.displayName = 'Tabs.List';
559
+
560
+ // =============================================================================
561
+ // STYLED TAB
562
+ // =============================================================================
563
+
564
+ export interface TabsTabProps extends Omit<ITabProps, 'children'> {
565
+ asChild?: boolean;
566
+ className?: string;
567
+ children?: ReactNode;
568
+ }
569
+
570
+ const TabsTab = forwardRef<ComponentRef<typeof Pressable>, TabsTabProps>(
571
+ ({ asChild, className, style, children, value: tabValue, onLayout, ...props }, ref) => {
572
+ const { reportLayout, clearLayout, variant } = useTabsIndicatorContext();
573
+ const { orientation } = useTabsContext();
574
+
575
+ useEffect(() => {
576
+ return () => clearLayout(tabValue);
577
+ }, [tabValue, clearLayout]);
578
+
579
+ const handleLayout = useCallback(
580
+ (e: LayoutChangeEvent) => {
581
+ onLayout?.(e);
582
+ const { x, y, width, height } = e.nativeEvent.layout;
583
+ reportLayout(tabValue, { x, y, width, height });
584
+ },
585
+ [onLayout, reportLayout, tabValue],
586
+ );
587
+
588
+ return (
589
+ <TabPrimitive
590
+ ref={ref}
591
+ asChild={asChild}
592
+ value={tabValue}
593
+ className={cn(tabsTabVariants({ orientation, variant }), className)}
594
+ style={
595
+ variant === 'solid'
596
+ ? (state) =>
597
+ StyleSheet.flatten([
598
+ tabsStyles.solidTabPressable,
599
+ typeof style === 'function' ? style(state) : style,
600
+ ])
601
+ : style
602
+ }
603
+ onLayout={handleLayout}
604
+ {...props}
605
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'tabs-tab' }))}
606
+ >
607
+ {children}
608
+ </TabPrimitive>
609
+ );
610
+ },
611
+ );
612
+
613
+ TabsTab.displayName = 'Tabs.Tab';
614
+
615
+ // =============================================================================
616
+ // STYLED TAB LABEL (styled-only, reads tab context for selected state)
617
+ // =============================================================================
618
+
619
+ export interface TabsTabLabelProps extends TextProps {
620
+ asChild?: boolean;
621
+ className?: string;
622
+ }
623
+
624
+ const TabsTabLabel = forwardRef<ComponentRef<typeof Text>, TabsTabLabelProps>(
625
+ ({ asChild, className, style, children, ...props }, ref) => {
626
+ const { selected } = useTabContext();
627
+ const Comp = asChild ? Slot : Text;
628
+
629
+ return (
630
+ <Comp
631
+ ref={ref}
632
+ className={cn(tabsTabLabelVariants({ state: selected ? 'active' : 'inactive' }), className)}
633
+ style={style}
634
+ {...props}
635
+ {...mergeDataAttributes(
636
+ props,
637
+ dataAttributes({ slot: 'tabs-tab-label', state: selected ? 'active' : 'inactive' }),
638
+ )}
639
+ >
640
+ {children}
641
+ </Comp>
642
+ );
643
+ },
644
+ );
645
+
646
+ TabsTabLabel.displayName = 'Tabs.TabLabel';
647
+
648
+ // =============================================================================
649
+ // STYLED PANEL
650
+ // =============================================================================
651
+
652
+ export interface TabsPanelProps extends ITabPanelProps {
653
+ asChild?: boolean;
654
+ className?: string;
655
+ }
656
+
657
+ const TabsPanel = forwardRef<ComponentRef<typeof View>, TabsPanelProps>(
658
+ ({ asChild, className, style, children, ...props }, ref) => {
659
+ const { orientation } = useTabsContext();
660
+
661
+ return (
662
+ <TabPanelPrimitive
663
+ ref={ref}
664
+ asChild={asChild}
665
+ className={cn(tabsPanelVariants({ orientation }), className)}
666
+ style={style}
667
+ {...props}
668
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'tabs-panel' }))}
669
+ >
670
+ {children}
671
+ </TabPanelPrimitive>
672
+ );
673
+ },
674
+ );
675
+
676
+ TabsPanel.displayName = 'Tabs.Panel';
677
+
678
+ // =============================================================================
679
+ // COMPOUND COMPONENT
680
+ // =============================================================================
681
+
682
+ type TabsCompoundComponent = typeof TabsRoot & {
683
+ List: typeof TabsList;
684
+ Tab: typeof TabsTab;
685
+ TabLabel: typeof TabsTabLabel;
686
+ Panel: typeof TabsPanel;
687
+ };
688
+
689
+ export const Tabs = Object.assign(TabsRoot, {
690
+ List: TabsList,
691
+ Tab: TabsTab,
692
+ TabLabel: TabsTabLabel,
693
+ Panel: TabsPanel,
694
+ }) as TabsCompoundComponent;