@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/LICENSE +21 -0
- package/dist/cjs/Tabs.js +340 -0
- package/dist/cjs/Tabs.js.map +7 -0
- package/dist/cjs/index.js +19 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/esm/Tabs.js +314 -0
- package/dist/esm/Tabs.js.map +7 -0
- package/dist/esm/Tabs.mjs +314 -0
- package/dist/esm/Tabs.mjs.map +7 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +7 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +7 -0
- package/dist/jsx/Tabs.js +287 -0
- package/dist/jsx/Tabs.js.map +7 -0
- package/dist/jsx/Tabs.mjs +287 -0
- package/dist/jsx/Tabs.mjs.map +7 -0
- package/dist/jsx/index.js +2 -0
- package/dist/jsx/index.js.map +7 -0
- package/dist/jsx/index.mjs +2 -0
- package/dist/jsx/index.mjs.map +7 -0
- package/package.json +42 -0
- package/src/Tabs.tsx +443 -0
- package/src/index.ts +1 -0
- package/types/Tabs.d.ts +619 -0
- package/types/Tabs.d.ts.map +1 -0
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -0
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'
|