@tamagui/tabs 1.7.0

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/src/Tabs.tsx ADDED
@@ -0,0 +1,443 @@
1
+ import type { Scope } from '@tamagui/create-context'
2
+ import { createContextScope } from '@tamagui/create-context'
3
+ import { getButtonSized } from '@tamagui/get-button-sized'
4
+ import { Group, GroupProps, useGroupItem } from '@tamagui/group'
5
+ import { RovingFocusGroup, createRovingFocusGroupScope } from '@tamagui/roving-focus'
6
+ import { SizableStack, ThemeableStack, ThemeableStackProps } from '@tamagui/stacks'
7
+ import { useControllableState } from '@tamagui/use-controllable-state'
8
+ import { useDirection } from '@tamagui/use-direction'
9
+ import {
10
+ GetProps,
11
+ SizeTokens,
12
+ Theme,
13
+ composeEventHandlers,
14
+ composeRefs,
15
+ isWeb,
16
+ styled,
17
+ useId,
18
+ withStaticProperties,
19
+ } from '@tamagui/web'
20
+ import * as React from 'react'
21
+ import type { LayoutRectangle } from 'react-native'
22
+
23
+ /* -------------------------------------------------------------------------------------------------
24
+ * TabsList
25
+ * -----------------------------------------------------------------------------------------------*/
26
+
27
+ const TAB_LIST_NAME = 'TabsList'
28
+
29
+ const TabsListFrame = styled(Group, {
30
+ name: TAB_LIST_NAME,
31
+ focusable: true,
32
+ // defaultVariants: {
33
+ // flexGrow: 0,
34
+ // },
35
+ })
36
+
37
+ type TabsListFrameProps = GroupProps
38
+
39
+ type TabsListProps = TabsListFrameProps & {
40
+ /**
41
+ * Whether to loop over after reaching the end or start of the items
42
+ * @default true
43
+ */
44
+ loop?: boolean
45
+ }
46
+
47
+ const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
48
+ (props: ScopedProps<TabsListProps>, forwardedRef) => {
49
+ const { __scopeTabs, loop = true, children, ...listProps } = props
50
+ const context = useTabsContext(TAB_LIST_NAME, __scopeTabs)
51
+ const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeTabs)
52
+
53
+ return (
54
+ <RovingFocusGroup
55
+ asChild
56
+ orientation={context.orientation}
57
+ dir={context.dir}
58
+ loop={loop}
59
+ {...rovingFocusGroupScope}
60
+ >
61
+ <TabsListFrame
62
+ role="tablist"
63
+ aria-orientation={context.orientation}
64
+ ref={forwardedRef}
65
+ axis={context.orientation}
66
+ {...listProps}
67
+ >
68
+ {children}
69
+ </TabsListFrame>
70
+ </RovingFocusGroup>
71
+ )
72
+ }
73
+ )
74
+
75
+ TabsList.displayName = TAB_LIST_NAME
76
+
77
+ /* -------------------------------------------------------------------------------------------------
78
+ * TabsTrigger
79
+ * -----------------------------------------------------------------------------------------------*/
80
+
81
+ const TRIGGER_NAME = 'TabsTrigger'
82
+
83
+ const TabsTriggerFrame = styled(ThemeableStack, {
84
+ name: TRIGGER_NAME,
85
+ justifyContent: 'center',
86
+ alignItems: 'center',
87
+ flexWrap: 'nowrap',
88
+ flexDirection: 'row',
89
+ cursor: 'pointer',
90
+
91
+ variants: {
92
+ size: {
93
+ '...size': getButtonSized,
94
+ },
95
+ disabled: {
96
+ true: {
97
+ pointerEvents: 'none',
98
+ },
99
+ },
100
+ theme: {
101
+ Button: {
102
+ focusable: true,
103
+ hoverTheme: true,
104
+ pressTheme: true,
105
+ backgrounded: true,
106
+ borderWidth: 1,
107
+ borderColor: 'transparent',
108
+
109
+ pressStyle: {
110
+ borderColor: 'transparent',
111
+ },
112
+
113
+ hoverStyle: {
114
+ borderColor: 'transparent',
115
+ },
116
+
117
+ focusStyle: {
118
+ borderColor: '$borderColorFocus',
119
+ },
120
+ },
121
+ },
122
+ },
123
+ })
124
+
125
+ type TabTriggerLayout = LayoutRectangle
126
+ type InteractionType = 'select' | 'focus' | 'hover'
127
+
128
+ type TabsTriggerFrameProps = ThemeableStackProps
129
+ type TabsTriggerProps = TabsTriggerFrameProps & {
130
+ /** The value for the tabs state to be changed to after activation of the trigger */
131
+ value: string
132
+
133
+ /** Used for making custom indicators when trigger interacted with */
134
+ onInteraction?: (type: InteractionType, layout: TabTriggerLayout | null) => void
135
+ }
136
+
137
+ const TabsTrigger = TabsTriggerFrame.extractable(
138
+ React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
139
+ (props: ScopedProps<TabsTriggerProps>, forwardedRef) => {
140
+ const {
141
+ __scopeTabs,
142
+ value,
143
+ disabled = false,
144
+ onInteraction,
145
+ ...triggerProps
146
+ } = props
147
+ const context = useTabsContext(TRIGGER_NAME, __scopeTabs)
148
+ const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeTabs)
149
+ const triggerId = makeTriggerId(context.baseId, value)
150
+ const contentId = makeContentId(context.baseId, value)
151
+ const isSelected = value === context.value
152
+ const [layout, setLayout] = React.useState<TabTriggerLayout | null>(null)
153
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
154
+ const groupItemProps = useGroupItem({ disabled })
155
+ React.useEffect(() => {
156
+ if (!triggerRef.current || !isWeb) return
157
+
158
+ function getTriggerSize() {
159
+ if (!triggerRef.current) return
160
+ setLayout({
161
+ width: triggerRef.current.offsetWidth,
162
+ height: triggerRef.current.offsetHeight,
163
+ x: triggerRef.current.offsetLeft,
164
+ y: triggerRef.current.offsetTop,
165
+ })
166
+ }
167
+ getTriggerSize()
168
+
169
+ const observer = new ResizeObserver(getTriggerSize)
170
+ observer.observe(triggerRef.current)
171
+
172
+ return () => {
173
+ if (!triggerRef.current) return
174
+ observer.unobserve(triggerRef.current)
175
+ }
176
+ }, [])
177
+
178
+ React.useEffect(() => {
179
+ if (isSelected && layout) {
180
+ onInteraction?.('select', layout)
181
+ }
182
+ }, [isSelected, value, layout])
183
+
184
+ return (
185
+ <Theme forceClassName name={isSelected ? 'active' : null}>
186
+ <RovingFocusGroup.Item
187
+ asChild
188
+ {...rovingFocusGroupScope}
189
+ focusable={!disabled}
190
+ active={isSelected}
191
+ >
192
+ <TabsTriggerFrame
193
+ onLayout={(event) => {
194
+ if (!isWeb) {
195
+ setLayout(event.nativeEvent.layout)
196
+ }
197
+ }}
198
+ onHoverIn={composeEventHandlers(props.onHoverIn, () => {
199
+ if (layout) {
200
+ onInteraction?.('hover', layout)
201
+ }
202
+ })}
203
+ onHoverOut={composeEventHandlers(props.onHoverOut, () => {
204
+ onInteraction?.('hover', null)
205
+ })}
206
+ role="tab"
207
+ aria-selected={isSelected}
208
+ aria-controls={contentId}
209
+ data-state={isSelected ? 'active' : 'inactive'}
210
+ data-disabled={disabled ? '' : undefined}
211
+ disabled={disabled}
212
+ id={triggerId}
213
+ // @ts-ignore
214
+ size={context.size}
215
+ {...triggerProps}
216
+ ref={composeRefs(forwardedRef, triggerRef)}
217
+ onPress={composeEventHandlers(props.onPress ?? undefined, (event) => {
218
+ // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
219
+ // but not when the control key is pressed (avoiding MacOS right click)
220
+
221
+ const webChecks =
222
+ !isWeb ||
223
+ ((event as unknown as React.MouseEvent).button === 0 &&
224
+ (event as unknown as React.MouseEvent).ctrlKey === false)
225
+ if (!disabled && !isSelected && webChecks) {
226
+ context.onChange(value)
227
+ } else {
228
+ // prevent focus to avoid accidental activation
229
+ event.preventDefault()
230
+ }
231
+ })}
232
+ {...(isWeb && {
233
+ type: 'button',
234
+ onKeyDown: composeEventHandlers(
235
+ (props as React.HTMLProps<HTMLButtonElement>).onKeyDown,
236
+ (event) => {
237
+ if ([' ', 'Enter'].includes(event.key)) {
238
+ context.onChange(value)
239
+ }
240
+ }
241
+ ),
242
+ onFocus: composeEventHandlers(props.onFocus, (event) => {
243
+ if (layout) {
244
+ onInteraction?.('focus', layout)
245
+ }
246
+ // handle "automatic" activation if necessary
247
+ // ie. activate tab following focus
248
+ const isAutomaticActivation = context.activationMode !== 'manual'
249
+ if (!isSelected && !disabled && isAutomaticActivation) {
250
+ context.onChange(value)
251
+ }
252
+ }),
253
+ onBlur: composeEventHandlers(props.onFocus, () => {
254
+ onInteraction?.('focus', null)
255
+ }),
256
+ })}
257
+ {...groupItemProps}
258
+ />
259
+ </RovingFocusGroup.Item>
260
+ </Theme>
261
+ )
262
+ }
263
+ )
264
+ )
265
+
266
+ TabsTrigger.displayName = TRIGGER_NAME
267
+
268
+ /* -------------------------------------------------------------------------------------------------
269
+ * TabsContent
270
+ * -----------------------------------------------------------------------------------------------*/
271
+
272
+ const CONTENT_NAME = 'TabsContent'
273
+
274
+ const TabsContentFrame = styled(ThemeableStack, {
275
+ name: CONTENT_NAME,
276
+ })
277
+ type TabsContentFrameProps = GetProps<typeof TabsContentFrame>
278
+ type TabsContentProps = TabsContentFrameProps & {
279
+ /** Will show the content when the value matches the state of Tabs root */
280
+ value: string
281
+
282
+ /**
283
+ * Used to force mounting when more control is needed. Useful when
284
+ * controlling animation with Tamagui animations.
285
+ */
286
+ forceMount?: true
287
+ }
288
+
289
+ const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
290
+ (props: ScopedProps<TabsContentProps>, forwardedRef) => {
291
+ const { __scopeTabs, value, forceMount, children, ...contentProps } = props
292
+ const context = useTabsContext(CONTENT_NAME, __scopeTabs)
293
+ const isSelected = value === context.value
294
+ const show = forceMount || isSelected
295
+
296
+ const triggerId = makeTriggerId(context.baseId, value)
297
+ const contentId = makeContentId(context.baseId, value)
298
+
299
+ if (!show) return null
300
+ return (
301
+ <TabsContentFrame
302
+ key={value}
303
+ data-state={isSelected ? 'active' : 'inactive'}
304
+ data-orientation={context.orientation}
305
+ role="tabpanel"
306
+ aria-labelledby={triggerId}
307
+ // @ts-ignore
308
+ hidden={!show}
309
+ id={contentId}
310
+ tabIndex={0}
311
+ {...contentProps}
312
+ ref={forwardedRef}
313
+ >
314
+ {children}
315
+ </TabsContentFrame>
316
+ )
317
+ }
318
+ )
319
+
320
+ TabsContent.displayName = CONTENT_NAME
321
+
322
+ /* -------------------------------------------------------------------------------------------------
323
+ * Tabs
324
+ * -----------------------------------------------------------------------------------------------*/
325
+
326
+ const TABS_NAME = 'Tabs'
327
+
328
+ type ScopedProps<P> = P & { __scopeTabs?: Scope }
329
+ const [createTabsContext, createTabsScope] = createContextScope(TABS_NAME, [
330
+ createRovingFocusGroupScope,
331
+ ])
332
+ const useRovingFocusGroupScope = createRovingFocusGroupScope()
333
+
334
+ type TabsContextValue = {
335
+ baseId: string
336
+ value?: string
337
+ onChange: (value: string) => void
338
+ orientation?: TabsProps['orientation']
339
+ dir?: TabsProps['dir']
340
+ activationMode?: TabsProps['activationMode']
341
+ size: SizeTokens
342
+ }
343
+
344
+ const [TabsProvider, useTabsContext] = createTabsContext<TabsContextValue>(TABS_NAME)
345
+
346
+ const TabsFrame = styled(SizableStack, {
347
+ name: TABS_NAME,
348
+ })
349
+ type RovingFocusGroupProps = React.ComponentPropsWithoutRef<typeof RovingFocusGroup>
350
+ type TabsFrameProps = GetProps<typeof TabsFrame>
351
+ type TabsProps = TabsFrameProps & {
352
+ /** The value for the selected tab, if controlled */
353
+ value?: string
354
+ /** The value of the tab to select by default, if uncontrolled */
355
+ defaultValue?: string
356
+ /** A function called when a new tab is selected */
357
+ onValueChange?: (value: string) => void
358
+ /**
359
+ * The orientation the tabs are layed out.
360
+ * Mainly so arrow navigation is done accordingly (left & right vs. up & down)
361
+ * @defaultValue horizontal
362
+ */
363
+ orientation?: RovingFocusGroupProps['orientation']
364
+ /**
365
+ * The direction of navigation between toolbar items.
366
+ */
367
+ dir?: RovingFocusGroupProps['dir']
368
+ /**
369
+ * Whether a tab is activated automatically or manually. Only supported in web.
370
+ * @defaultValue automatic
371
+ * */
372
+ activationMode?: 'automatic' | 'manual'
373
+ }
374
+
375
+ export const Tabs = withStaticProperties(
376
+ React.forwardRef<HTMLDivElement, TabsProps>(
377
+ (props: ScopedProps<TabsProps>, forwardedRef) => {
378
+ const {
379
+ __scopeTabs,
380
+ value: valueProp,
381
+ onValueChange,
382
+ defaultValue,
383
+ orientation = 'horizontal',
384
+ dir,
385
+ activationMode = 'automatic',
386
+ size = '$true',
387
+ ...tabsProps
388
+ } = props
389
+ const direction = useDirection(dir)
390
+ const [value, setValue] = useControllableState({
391
+ prop: valueProp,
392
+ onChange: onValueChange,
393
+ defaultProp: defaultValue ?? '',
394
+ })
395
+
396
+ return (
397
+ <TabsProvider
398
+ scope={__scopeTabs}
399
+ baseId={useId()}
400
+ value={value}
401
+ onChange={setValue}
402
+ orientation={orientation}
403
+ dir={direction}
404
+ activationMode={activationMode}
405
+ size={size}
406
+ >
407
+ <TabsFrame
408
+ direction={direction}
409
+ // dir={direction}
410
+ data-orientation={orientation}
411
+ {...tabsProps}
412
+ ref={forwardedRef}
413
+ />
414
+ </TabsProvider>
415
+ )
416
+ }
417
+ ),
418
+ {
419
+ List: TabsList,
420
+ Trigger: TabsTrigger,
421
+ Content: TabsContent,
422
+ }
423
+ )
424
+
425
+ Tabs.displayName = TABS_NAME
426
+
427
+ /* ---------------------------------------------------------------------------------------------- */
428
+
429
+ function makeTriggerId(baseId: string, value: string) {
430
+ return `${baseId}-trigger-${value}`
431
+ }
432
+
433
+ function makeContentId(baseId: string, value: string) {
434
+ return `${baseId}-content-${value}`
435
+ }
436
+
437
+ export type {
438
+ TabsProps,
439
+ TabsListProps,
440
+ TabsTriggerProps,
441
+ TabsContentProps,
442
+ TabTriggerLayout,
443
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './Tabs'