@hanzogui/tabs-headless 2.0.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.
@@ -0,0 +1,406 @@
1
+ import { composeEventHandlers } from '@hanzogui/helpers'
2
+ import { useControllableState } from '@hanzogui/use-controllable-state'
3
+ import { useDirection } from '@hanzogui/use-direction'
4
+ import * as React from 'react'
5
+ import { useCallback, useId, useRef, useState } from 'react'
6
+
7
+ export type TabsActivationMode = 'automatic' | 'manual'
8
+ export type TabsOrientation = 'horizontal' | 'vertical'
9
+ export type Direction = 'ltr' | 'rtl'
10
+
11
+ // -------------------------------------------------------------------------------------------------
12
+ // useTabs
13
+ // -------------------------------------------------------------------------------------------------
14
+
15
+ export interface UseTabsProps {
16
+ /** The value for the selected tab, if controlled */
17
+ value?: string
18
+ /** The value of the tab to select by default, if uncontrolled */
19
+ defaultValue?: string
20
+ /** A function called when a new tab is selected */
21
+ onValueChange?: (value: string) => void
22
+ /**
23
+ * The orientation the tabs are layed out.
24
+ * Mainly so arrow navigation is done accordingly (left & right vs. up & down)
25
+ * @defaultValue horizontal
26
+ */
27
+ orientation?: TabsOrientation
28
+ /**
29
+ * The direction of navigation between toolbar items.
30
+ */
31
+ dir?: Direction
32
+ /**
33
+ * Whether a tab is activated automatically (on focus) or manually (on click/enter).
34
+ * @defaultValue automatic
35
+ */
36
+ activationMode?: TabsActivationMode
37
+ /**
38
+ * Whether keyboard navigation should loop from last to first and vice versa.
39
+ * @defaultValue true
40
+ */
41
+ loop?: boolean
42
+ }
43
+
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 {
104
+ const {
105
+ value: valueProp,
106
+ onValueChange,
107
+ defaultValue = '',
108
+ orientation = 'horizontal',
109
+ dir,
110
+ activationMode = 'automatic',
111
+ loop = true,
112
+ } = props
113
+
114
+ const direction = useDirection(dir)
115
+ const baseId = useId()
116
+
117
+ const [value, setValue] = useControllableState({
118
+ prop: valueProp,
119
+ onChange: onValueChange,
120
+ defaultProp: defaultValue,
121
+ })
122
+
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]
209
+ )
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]
260
+ )
261
+
262
+ const contextValue: TabsContextValue = {
263
+ baseId,
264
+ value,
265
+ setValue,
266
+ orientation,
267
+ direction,
268
+ activationMode,
269
+ loop,
270
+ }
271
+
272
+ return {
273
+ value,
274
+ setValue,
275
+ direction,
276
+ tabsProps: {
277
+ 'data-orientation': orientation,
278
+ dir: direction,
279
+ },
280
+ listProps: {
281
+ role: 'tablist',
282
+ 'aria-orientation': orientation,
283
+ },
284
+ getTabProps,
285
+ getContentProps,
286
+ contextValue,
287
+ }
288
+ }
289
+
290
+ // -------------------------------------------------------------------------------------------------
291
+ // useTabsContext - for component-based APIs
292
+ // -------------------------------------------------------------------------------------------------
293
+
294
+ const TabsContext = React.createContext<TabsContextValue | null>(null)
295
+
296
+ export const TabsProvider = TabsContext.Provider
297
+
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')
302
+ }
303
+ return context
304
+ }
305
+
306
+ // -------------------------------------------------------------------------------------------------
307
+ // useTab - hook for individual tab triggers
308
+ // -------------------------------------------------------------------------------------------------
309
+
310
+ export interface UseTabProps {
311
+ value: string
312
+ disabled?: boolean
313
+ onPress?: (event: any) => void
314
+ onKeyDown?: (event: React.KeyboardEvent) => void
315
+ onFocus?: (event: React.FocusEvent) => void
316
+ }
317
+
318
+ 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)
327
+
328
+ const ref = useRef<HTMLElement>(null)
329
+
330
+ return {
331
+ isSelected,
332
+ tabProps: {
333
+ ref,
334
+ role: 'tab' as const,
335
+ id: triggerId,
336
+ 'aria-selected': isSelected,
337
+ 'aria-controls': contentId,
338
+ 'data-state': isSelected ? 'active' : 'inactive',
339
+ ...(disabled && { 'data-disabled': '' as const }),
340
+ 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)
357
+ }
358
+ }),
359
+ },
360
+ }
361
+ }
362
+
363
+ // -------------------------------------------------------------------------------------------------
364
+ // useTabContent - hook for tab content panels
365
+ // -------------------------------------------------------------------------------------------------
366
+
367
+ export interface UseTabContentProps {
368
+ value: string
369
+ forceMount?: boolean
370
+ }
371
+
372
+ export function useTabContent(props: UseTabContentProps) {
373
+ const { value: tabValue, forceMount } = props
374
+ const context = useTabsContext()
375
+ const { value, baseId, orientation } = context
376
+
377
+ const isSelected = value === tabValue
378
+ const triggerId = makeTriggerId(baseId, tabValue)
379
+ const contentId = makeContentId(baseId, tabValue)
380
+
381
+ return {
382
+ isSelected,
383
+ shouldMount: forceMount || isSelected,
384
+ contentProps: {
385
+ role: 'tabpanel' as const,
386
+ id: contentId,
387
+ 'aria-labelledby': triggerId,
388
+ 'data-state': isSelected ? 'active' : 'inactive',
389
+ 'data-orientation': orientation,
390
+ hidden: !isSelected,
391
+ tabIndex: 0,
392
+ },
393
+ }
394
+ }
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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './useTabs';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,132 @@
1
+ import * as React from 'react';
2
+ export type TabsActivationMode = 'automatic' | 'manual';
3
+ export type TabsOrientation = 'horizontal' | 'vertical';
4
+ export type Direction = 'ltr' | 'rtl';
5
+ export interface UseTabsProps {
6
+ /** The value for the selected tab, if controlled */
7
+ value?: string;
8
+ /** The value of the tab to select by default, if uncontrolled */
9
+ defaultValue?: string;
10
+ /** A function called when a new tab is selected */
11
+ onValueChange?: (value: string) => void;
12
+ /**
13
+ * The orientation the tabs are layed out.
14
+ * Mainly so arrow navigation is done accordingly (left & right vs. up & down)
15
+ * @defaultValue horizontal
16
+ */
17
+ orientation?: TabsOrientation;
18
+ /**
19
+ * The direction of navigation between toolbar items.
20
+ */
21
+ dir?: Direction;
22
+ /**
23
+ * Whether a tab is activated automatically (on focus) or manually (on click/enter).
24
+ * @defaultValue automatic
25
+ */
26
+ activationMode?: TabsActivationMode;
27
+ /**
28
+ * Whether keyboard navigation should loop from last to first and vice versa.
29
+ * @defaultValue true
30
+ */
31
+ loop?: boolean;
32
+ }
33
+ export interface UseTabsReturn {
34
+ /** The currently selected tab value */
35
+ value: string;
36
+ /** Function to change the selected tab */
37
+ setValue: (value: string) => void;
38
+ /** The resolved text direction */
39
+ direction: Direction;
40
+ /** Props to spread on the tabs container element */
41
+ tabsProps: {
42
+ 'data-orientation': TabsOrientation;
43
+ dir: Direction;
44
+ };
45
+ /** Props to spread on the tab list element */
46
+ listProps: {
47
+ role: 'tablist';
48
+ 'aria-orientation': TabsOrientation;
49
+ };
50
+ /** Function to get props for a tab trigger */
51
+ getTabProps: (tabValue: string, disabled?: boolean) => TabTriggerProps;
52
+ /** Function to get props for a tab content panel */
53
+ getContentProps: (tabValue: string) => TabContentProps;
54
+ /** Context value to provide to child components */
55
+ contextValue: TabsContextValue;
56
+ }
57
+ export interface TabTriggerProps {
58
+ role: 'tab';
59
+ id: string;
60
+ 'aria-selected': boolean;
61
+ 'aria-controls': string;
62
+ 'data-state': 'active' | 'inactive';
63
+ 'data-disabled'?: '';
64
+ disabled?: boolean;
65
+ tabIndex: number;
66
+ onKeyDown: (event: React.KeyboardEvent) => void;
67
+ onClick: (event: React.MouseEvent) => void;
68
+ onFocus: (event: React.FocusEvent) => void;
69
+ }
70
+ export interface TabContentProps {
71
+ role: 'tabpanel';
72
+ id: string;
73
+ 'aria-labelledby': string;
74
+ 'data-state': 'active' | 'inactive';
75
+ 'data-orientation': TabsOrientation;
76
+ hidden: boolean;
77
+ tabIndex: 0;
78
+ }
79
+ export interface TabsContextValue {
80
+ baseId: string;
81
+ value: string;
82
+ setValue: (value: string) => void;
83
+ orientation: TabsOrientation;
84
+ direction: Direction;
85
+ activationMode: TabsActivationMode;
86
+ loop: boolean;
87
+ }
88
+ export declare function useTabs(props?: UseTabsProps): UseTabsReturn;
89
+ export declare const TabsProvider: React.Provider<TabsContextValue | null>;
90
+ export declare function useTabsContext(): TabsContextValue;
91
+ export interface UseTabProps {
92
+ value: string;
93
+ disabled?: boolean;
94
+ onPress?: (event: any) => void;
95
+ onKeyDown?: (event: React.KeyboardEvent) => void;
96
+ onFocus?: (event: React.FocusEvent) => void;
97
+ }
98
+ export declare function useTab(props: UseTabProps): {
99
+ isSelected: boolean;
100
+ tabProps: {
101
+ disabled: boolean | undefined;
102
+ tabIndex: number;
103
+ onKeyDown: import("@gui/helpers").EventHandler<React.KeyboardEvent<Element>> | undefined;
104
+ onPress: import("@gui/helpers").EventHandler<any> | undefined;
105
+ onFocus: import("@gui/helpers").EventHandler<React.FocusEvent<Element, Element>> | undefined;
106
+ 'data-disabled'?: "" | undefined;
107
+ ref: React.RefObject<HTMLElement | null>;
108
+ role: "tab";
109
+ id: string;
110
+ 'aria-selected': boolean;
111
+ 'aria-controls': string;
112
+ 'data-state': string;
113
+ };
114
+ };
115
+ export interface UseTabContentProps {
116
+ value: string;
117
+ forceMount?: boolean;
118
+ }
119
+ export declare function useTabContent(props: UseTabContentProps): {
120
+ isSelected: boolean;
121
+ shouldMount: boolean;
122
+ contentProps: {
123
+ role: "tabpanel";
124
+ id: string;
125
+ 'aria-labelledby': string;
126
+ 'data-state': string;
127
+ 'data-orientation': TabsOrientation;
128
+ hidden: boolean;
129
+ tabIndex: number;
130
+ };
131
+ };
132
+ //# sourceMappingURL=useTabs.d.ts.map