@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.
- package/lib/commonjs/components/ScrollShadow/index.js +82 -0
- package/lib/commonjs/components/ScrollShadow/index.js.map +1 -0
- package/lib/commonjs/components/ScrollShadow/styles.js +54 -0
- package/lib/commonjs/components/ScrollShadow/styles.js.map +1 -0
- package/lib/commonjs/components/ScrollShadow/useScrollShadow.js +83 -0
- package/lib/commonjs/components/ScrollShadow/useScrollShadow.js.map +1 -0
- package/lib/commonjs/components/Tabs/index.js +525 -0
- package/lib/commonjs/components/Tabs/index.js.map +1 -0
- package/lib/commonjs/components/Tabs/styles.js +333 -0
- package/lib/commonjs/components/Tabs/styles.js.map +1 -0
- package/lib/commonjs/components/index.js +24 -0
- package/lib/commonjs/components/index.js.map +1 -1
- package/lib/module/components/ScrollShadow/index.js +78 -0
- package/lib/module/components/ScrollShadow/index.js.map +1 -0
- package/lib/module/components/ScrollShadow/styles.js +50 -0
- package/lib/module/components/ScrollShadow/styles.js.map +1 -0
- package/lib/module/components/ScrollShadow/useScrollShadow.js +79 -0
- package/lib/module/components/ScrollShadow/useScrollShadow.js.map +1 -0
- package/lib/module/components/Tabs/index.js +520 -0
- package/lib/module/components/Tabs/index.js.map +1 -0
- package/lib/module/components/Tabs/styles.js +330 -0
- package/lib/module/components/Tabs/styles.js.map +1 -0
- package/lib/module/components/index.js +2 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/typescript/components/ScrollShadow/index.d.ts +29 -0
- package/lib/typescript/components/ScrollShadow/index.d.ts.map +1 -0
- package/lib/typescript/components/ScrollShadow/styles.d.ts +12 -0
- package/lib/typescript/components/ScrollShadow/styles.d.ts.map +1 -0
- package/lib/typescript/components/ScrollShadow/useScrollShadow.d.ts +17 -0
- package/lib/typescript/components/ScrollShadow/useScrollShadow.d.ts.map +1 -0
- package/lib/typescript/components/Tabs/index.d.ts +48 -0
- package/lib/typescript/components/Tabs/index.d.ts.map +1 -0
- package/lib/typescript/components/Tabs/styles.d.ts +107 -0
- package/lib/typescript/components/Tabs/styles.d.ts.map +1 -0
- package/lib/typescript/components/index.d.ts +2 -0
- package/lib/typescript/components/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components/ScrollShadow/index.tsx +119 -0
- package/src/components/ScrollShadow/styles.ts +57 -0
- package/src/components/ScrollShadow/useScrollShadow.ts +110 -0
- package/src/components/Tabs/index.tsx +694 -0
- package/src/components/Tabs/styles.ts +340 -0
- 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;
|