@tamagui/tabs-headless 2.7.7 → 3.0.0-beta.643.1

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/useTabs.tsx CHANGED
@@ -1,15 +1,24 @@
1
- import { composeEventHandlers } from '@tamagui/helpers'
1
+ import { isWeb } from '@tamagui/constants'
2
2
  import { useControllableState } from '@tamagui/use-controllable-state'
3
3
  import { useDirection } from '@tamagui/use-direction'
4
4
  import * as React from 'react'
5
- import { useCallback, useId, useRef, useState } from 'react'
6
5
 
7
6
  export type TabsActivationMode = 'automatic' | 'manual'
8
7
  export type TabsOrientation = 'horizontal' | 'vertical'
9
8
  export type Direction = 'ltr' | 'rtl'
10
9
 
10
+ // the skinned Tabs and any headless consumer must derive the same ids, or
11
+ // aria-controls / aria-labelledby stop pointing at each other
12
+ export function makeTriggerId(baseId: string, value: string) {
13
+ return `${baseId}-trigger-${value}`
14
+ }
15
+
16
+ export function makeContentId(baseId: string, value: string) {
17
+ return `${baseId}-content-${value}`
18
+ }
19
+
11
20
  // -------------------------------------------------------------------------------------------------
12
- // useTabs
21
+ // useTabs - root controller
13
22
  // -------------------------------------------------------------------------------------------------
14
23
 
15
24
  export interface UseTabsProps {
@@ -20,387 +29,194 @@ export interface UseTabsProps {
20
29
  /** A function called when a new tab is selected */
21
30
  onValueChange?: (value: string) => void
22
31
  /**
23
- * The orientation the tabs are layed out.
24
- * Mainly so arrow navigation is done accordingly (left & right vs. up & down)
32
+ * The orientation the tabs are laid out.
25
33
  * @defaultValue horizontal
26
34
  */
27
35
  orientation?: TabsOrientation
28
- /**
29
- * The direction of navigation between toolbar items.
30
- */
36
+ /** The direction of navigation between tab triggers. */
31
37
  dir?: Direction
32
38
  /**
33
- * Whether a tab is activated automatically (on focus) or manually (on click/enter).
39
+ * Whether a tab is activated automatically (on focus) or manually (on press/enter).
40
+ * Automatic activation is web-only; native always activates manually.
34
41
  * @defaultValue automatic
35
42
  */
36
43
  activationMode?: TabsActivationMode
37
- /**
38
- * Whether keyboard navigation should loop from last to first and vice versa.
39
- * @defaultValue true
40
- */
41
- loop?: boolean
42
44
  }
43
45
 
44
- export interface UseTabsReturn {
45
- /** The currently selected tab value */
46
- value: string
47
- /** Function to change the selected tab */
48
- setValue: (value: string) => void
49
- /** The resolved text direction */
50
- direction: Direction
51
- /** Props to spread on the tabs container element */
52
- tabsProps: {
53
- 'data-orientation': TabsOrientation
54
- dir: Direction
55
- }
56
- /** Props to spread on the tab list element */
57
- listProps: {
58
- role: 'tablist'
59
- 'aria-orientation': TabsOrientation
60
- }
61
- /** Function to get props for a tab trigger */
62
- getTabProps: (tabValue: string, disabled?: boolean) => TabTriggerProps
63
- /** Function to get props for a tab content panel */
64
- getContentProps: (tabValue: string) => TabContentProps
65
- /** Context value to provide to child components */
66
- contextValue: TabsContextValue
67
- }
68
-
69
- export interface TabTriggerProps {
70
- role: 'tab'
71
- id: string
72
- 'aria-selected': boolean
73
- 'aria-controls': string
74
- 'data-state': 'active' | 'inactive'
75
- 'data-disabled'?: ''
76
- disabled?: boolean
77
- tabIndex: number
78
- onKeyDown: (event: React.KeyboardEvent) => void
79
- onClick: (event: React.MouseEvent) => void
80
- onFocus: (event: React.FocusEvent) => void
81
- }
82
-
83
- export interface TabContentProps {
84
- role: 'tabpanel'
85
- id: string
86
- 'aria-labelledby': string
87
- 'data-state': 'active' | 'inactive'
88
- 'data-orientation': TabsOrientation
89
- hidden: boolean
90
- tabIndex: 0
91
- }
92
-
93
- export interface TabsContextValue {
94
- baseId: string
95
- value: string
96
- setValue: (value: string) => void
97
- orientation: TabsOrientation
98
- direction: Direction
99
- activationMode: TabsActivationMode
100
- loop: boolean
101
- }
102
-
103
- export function useTabs(props: UseTabsProps = {}): UseTabsReturn {
46
+ export function useTabs(props: UseTabsProps = {}) {
104
47
  const {
105
48
  value: valueProp,
106
49
  onValueChange,
107
- defaultValue = '',
50
+ defaultValue,
108
51
  orientation = 'horizontal',
109
52
  dir,
110
53
  activationMode = 'automatic',
111
- loop = true,
112
54
  } = props
113
55
 
114
56
  const direction = useDirection(dir)
115
- const baseId = useId()
57
+ const baseId = React.useId()
116
58
 
117
59
  const [value, setValue] = useControllableState({
118
60
  prop: valueProp,
119
61
  onChange: onValueChange,
120
- defaultProp: defaultValue,
62
+ defaultProp: defaultValue ?? '',
121
63
  })
122
64
 
123
- const tabRefs = useRef<Map<string, HTMLElement>>(new Map())
124
- const [tabValues, setTabValues] = useState<string[]>([])
125
-
126
- const registerTab = useCallback((tabValue: string, element: HTMLElement | null) => {
127
- if (element) {
128
- tabRefs.current.set(tabValue, element)
129
- setTabValues((prev) => (prev.includes(tabValue) ? prev : [...prev, tabValue]))
130
- } else {
131
- tabRefs.current.delete(tabValue)
132
- setTabValues((prev) => prev.filter((v) => v !== tabValue))
133
- }
134
- }, [])
135
-
136
- const focusTab = useCallback((tabValue: string) => {
137
- const element = tabRefs.current.get(tabValue)
138
- element?.focus()
139
- }, [])
140
-
141
- const getNextTab = useCallback(
142
- (currentValue: string, direction: 1 | -1): string | null => {
143
- const currentIndex = tabValues.indexOf(currentValue)
144
- if (currentIndex === -1) return null
145
-
146
- let nextIndex = currentIndex + direction
147
- if (loop) {
148
- nextIndex = (nextIndex + tabValues.length) % tabValues.length
149
- } else {
150
- if (nextIndex < 0 || nextIndex >= tabValues.length) return null
151
- }
152
- return tabValues[nextIndex] ?? null
153
- },
154
- [tabValues, loop]
155
- )
156
-
157
- const handleKeyDown = useCallback(
158
- (tabValue: string) => (event: React.KeyboardEvent) => {
159
- const isHorizontal = orientation === 'horizontal'
160
- const isRtl = direction === 'rtl'
161
-
162
- let nextTab: string | null = null
163
-
164
- switch (event.key) {
165
- case 'ArrowRight':
166
- if (isHorizontal) {
167
- nextTab = getNextTab(tabValue, isRtl ? -1 : 1)
168
- }
169
- break
170
- case 'ArrowLeft':
171
- if (isHorizontal) {
172
- nextTab = getNextTab(tabValue, isRtl ? 1 : -1)
173
- }
174
- break
175
- case 'ArrowDown':
176
- if (!isHorizontal) {
177
- nextTab = getNextTab(tabValue, 1)
178
- }
179
- break
180
- case 'ArrowUp':
181
- if (!isHorizontal) {
182
- nextTab = getNextTab(tabValue, -1)
183
- }
184
- break
185
- case 'Home':
186
- nextTab = tabValues[0] ?? null
187
- break
188
- case 'End':
189
- nextTab = tabValues[tabValues.length - 1] ?? null
190
- break
191
- case ' ':
192
- case 'Enter':
193
- if (activationMode === 'manual') {
194
- setValue(tabValue)
195
- event.preventDefault()
196
- }
197
- return
198
- }
199
-
200
- if (nextTab) {
201
- event.preventDefault()
202
- focusTab(nextTab)
203
- if (activationMode === 'automatic') {
204
- setValue(nextTab)
205
- }
206
- }
207
- },
208
- [orientation, direction, getNextTab, tabValues, activationMode, setValue, focusTab]
65
+ // triggers report their presence so consumers can re-measure when the set changes
66
+ const [triggersCount, setTriggersCount] = React.useState(0)
67
+ const registerTrigger = React.useCallback(
68
+ () => setTriggersCount((count) => count + 1),
69
+ []
209
70
  )
210
-
211
- const getTabProps = useCallback(
212
- (tabValue: string, disabled?: boolean): TabTriggerProps => {
213
- const isSelected = value === tabValue
214
- const triggerId = makeTriggerId(baseId, tabValue)
215
- const contentId = makeContentId(baseId, tabValue)
216
-
217
- return {
218
- role: 'tab',
219
- id: triggerId,
220
- 'aria-selected': isSelected,
221
- 'aria-controls': contentId,
222
- 'data-state': isSelected ? 'active' : 'inactive',
223
- ...(disabled && { 'data-disabled': '' as const }),
224
- disabled,
225
- tabIndex: isSelected ? 0 : -1,
226
- onKeyDown: disabled ? () => {} : handleKeyDown(tabValue),
227
- onClick: (event: React.MouseEvent) => {
228
- if (!disabled && !isSelected) {
229
- setValue(tabValue)
230
- }
231
- },
232
- onFocus: (event: React.FocusEvent) => {
233
- registerTab(tabValue, event.currentTarget as HTMLElement)
234
- if (!disabled && !isSelected && activationMode === 'automatic') {
235
- setValue(tabValue)
236
- }
237
- },
238
- }
239
- },
240
- [value, baseId, handleKeyDown, setValue, activationMode, registerTab]
241
- )
242
-
243
- const getContentProps = useCallback(
244
- (tabValue: string): TabContentProps => {
245
- const isSelected = value === tabValue
246
- const triggerId = makeTriggerId(baseId, tabValue)
247
- const contentId = makeContentId(baseId, tabValue)
248
-
249
- return {
250
- role: 'tabpanel',
251
- id: contentId,
252
- 'aria-labelledby': triggerId,
253
- 'data-state': isSelected ? 'active' : 'inactive',
254
- 'data-orientation': orientation,
255
- hidden: !isSelected,
256
- tabIndex: 0,
257
- }
258
- },
259
- [value, baseId, orientation]
71
+ const unregisterTrigger = React.useCallback(
72
+ () => setTriggersCount((count) => count - 1),
73
+ []
260
74
  )
261
75
 
262
- const contextValue: TabsContextValue = {
263
- baseId,
264
- value,
265
- setValue,
266
- orientation,
267
- direction,
268
- activationMode,
269
- loop,
270
- }
271
-
272
76
  return {
273
77
  value,
274
78
  setValue,
79
+ baseId,
275
80
  direction,
81
+ orientation,
82
+ activationMode,
83
+ triggersCount,
84
+ registerTrigger,
85
+ unregisterTrigger,
276
86
  tabsProps: {
277
87
  'data-orientation': orientation,
278
- dir: direction,
279
- },
280
- listProps: {
281
- role: 'tablist',
282
- 'aria-orientation': orientation,
283
88
  },
284
- getTabProps,
285
- getContentProps,
286
- contextValue,
287
89
  }
288
90
  }
289
91
 
290
92
  // -------------------------------------------------------------------------------------------------
291
- // useTabsContext - for component-based APIs
93
+ // useTabsList - the tablist container
292
94
  // -------------------------------------------------------------------------------------------------
293
95
 
294
- const TabsContext = React.createContext<TabsContextValue | null>(null)
96
+ export interface UseTabsListProps {
97
+ orientation?: TabsOrientation
98
+ /** Disables every trigger in the list. */
99
+ disabled?: boolean
100
+ }
295
101
 
296
- export const TabsProvider = TabsContext.Provider
102
+ export function useTabsList(props: UseTabsListProps) {
103
+ const { orientation = 'horizontal', disabled = false } = props
297
104
 
298
- export function useTabsContext(): TabsContextValue {
299
- const context = React.useContext(TabsContext)
300
- if (!context) {
301
- throw new Error('useTabsContext must be used within a TabsProvider')
105
+ return {
106
+ listProps: {
107
+ role: 'tablist' as const,
108
+ 'aria-orientation': orientation,
109
+ 'aria-disabled': disabled || undefined,
110
+ 'data-orientation': orientation,
111
+ 'data-disabled': disabled ? ('' as const) : undefined,
112
+ },
302
113
  }
303
- return context
304
114
  }
305
115
 
306
116
  // -------------------------------------------------------------------------------------------------
307
- // useTab - hook for individual tab triggers
117
+ // useTab - an individual trigger
308
118
  // -------------------------------------------------------------------------------------------------
309
119
 
120
+ /** the fields the primary-pointer check reads off a web press event */
121
+ export type TabPressEvent = {
122
+ button?: number
123
+ ctrlKey?: boolean
124
+ }
125
+
310
126
  export interface UseTabProps {
127
+ baseId: string
128
+ /** The value this trigger selects. */
311
129
  value: string
130
+ /** The currently selected value. */
131
+ selectedValue?: string
312
132
  disabled?: boolean
313
- onPress?: (event: any) => void
314
- onKeyDown?: (event: React.KeyboardEvent) => void
315
- onFocus?: (event: React.FocusEvent) => void
133
+ activationMode?: TabsActivationMode
134
+ onChange: (value: string) => void
316
135
  }
317
136
 
318
137
  export function useTab(props: UseTabProps) {
319
- const { value: tabValue, disabled, onPress, onKeyDown, onFocus } = props
320
- const context = useTabsContext()
321
- const { value, setValue, baseId, activationMode, orientation, direction, loop } =
322
- context
323
-
324
- const isSelected = value === tabValue
325
- const triggerId = makeTriggerId(baseId, tabValue)
326
- const contentId = makeContentId(baseId, tabValue)
138
+ const {
139
+ baseId,
140
+ value,
141
+ selectedValue,
142
+ disabled = false,
143
+ activationMode = 'automatic',
144
+ onChange,
145
+ } = props
327
146
 
328
- const ref = useRef<HTMLElement>(null)
147
+ const isSelected = value === selectedValue
148
+ const triggerId = makeTriggerId(baseId, value)
149
+ const contentId = makeContentId(baseId, value)
329
150
 
330
151
  return {
331
152
  isSelected,
153
+ triggerId,
154
+ contentId,
332
155
  tabProps: {
333
- ref,
334
156
  role: 'tab' as const,
335
157
  id: triggerId,
336
158
  'aria-selected': isSelected,
337
159
  'aria-controls': contentId,
338
- 'data-state': isSelected ? 'active' : 'inactive',
339
- ...(disabled && { 'data-disabled': '' as const }),
160
+ 'data-state': isSelected ? ('active' as const) : ('inactive' as const),
161
+ 'data-disabled': disabled ? ('' as const) : undefined,
340
162
  disabled,
341
- tabIndex: isSelected ? 0 : -1,
342
- onKeyDown: composeEventHandlers(onKeyDown, (event: React.KeyboardEvent) => {
343
- if (disabled) return
344
- if ([' ', 'Enter'].includes(event.key)) {
345
- setValue(tabValue)
346
- event.preventDefault()
347
- }
348
- }),
349
- onPress: composeEventHandlers(onPress, () => {
350
- if (!disabled && !isSelected) {
351
- setValue(tabValue)
352
- }
353
- }),
354
- onFocus: composeEventHandlers(onFocus, () => {
355
- if (!disabled && !isSelected && activationMode === 'automatic') {
356
- setValue(tabValue)
163
+ onPress: (event?: TabPressEvent) => {
164
+ // ignore secondary and ctrl-clicks on web; native has no such notion
165
+ const isPrimaryPointer =
166
+ !isWeb || (event?.button === 0 && event?.ctrlKey === false)
167
+ if (!disabled && !isSelected && isPrimaryPointer) {
168
+ onChange(value)
357
169
  }
170
+ },
171
+ ...(isWeb && {
172
+ onKeyDown: (event: React.KeyboardEvent) => {
173
+ if (!disabled && [' ', 'Enter'].includes(event.key)) {
174
+ onChange(value)
175
+ event.preventDefault()
176
+ }
177
+ },
178
+ onFocus: () => {
179
+ if (!isSelected && !disabled && activationMode !== 'manual') {
180
+ onChange(value)
181
+ }
182
+ },
358
183
  }),
359
184
  },
360
185
  }
361
186
  }
362
187
 
363
188
  // -------------------------------------------------------------------------------------------------
364
- // useTabContent - hook for tab content panels
189
+ // useTabContent - a content panel
365
190
  // -------------------------------------------------------------------------------------------------
366
191
 
367
192
  export interface UseTabContentProps {
193
+ baseId: string
194
+ /** The value that selects this content. */
368
195
  value: string
196
+ /** The currently selected value. */
197
+ selectedValue?: string
198
+ orientation?: TabsOrientation
199
+ /** Mounts the content even when its value is not selected. */
369
200
  forceMount?: boolean
370
201
  }
371
202
 
372
203
  export function useTabContent(props: UseTabContentProps) {
373
- const { value: tabValue, forceMount } = props
374
- const context = useTabsContext()
375
- const { value, baseId, orientation } = context
204
+ const { baseId, value, selectedValue, orientation = 'horizontal', forceMount } = props
376
205
 
377
- const isSelected = value === tabValue
378
- const triggerId = makeTriggerId(baseId, tabValue)
379
- const contentId = makeContentId(baseId, tabValue)
206
+ const isSelected = value === selectedValue
207
+ const shouldMount = Boolean(forceMount) || isSelected
380
208
 
381
209
  return {
382
210
  isSelected,
383
- shouldMount: forceMount || isSelected,
211
+ shouldMount,
384
212
  contentProps: {
385
213
  role: 'tabpanel' as const,
386
- id: contentId,
387
- 'aria-labelledby': triggerId,
388
- 'data-state': isSelected ? 'active' : 'inactive',
214
+ id: makeContentId(baseId, value),
215
+ 'aria-labelledby': makeTriggerId(baseId, value),
216
+ 'data-state': isSelected ? ('active' as const) : ('inactive' as const),
389
217
  'data-orientation': orientation,
390
- hidden: !isSelected,
218
+ hidden: !shouldMount,
391
219
  tabIndex: 0,
392
220
  },
393
221
  }
394
222
  }
395
-
396
- // -------------------------------------------------------------------------------------------------
397
- // Helpers
398
- // -------------------------------------------------------------------------------------------------
399
-
400
- function makeTriggerId(baseId: string, value: string) {
401
- return `${baseId}-trigger-${value}`
402
- }
403
-
404
- function makeContentId(baseId: string, value: string) {
405
- return `${baseId}-content-${value}`
406
- }