@cdx-ui/primitives 0.0.1-beta.121 → 0.0.1-beta.123

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.
Files changed (64) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/tabs/Tab.js +122 -0
  4. package/lib/commonjs/tabs/Tab.js.map +1 -0
  5. package/lib/commonjs/tabs/TabPanel.js +53 -0
  6. package/lib/commonjs/tabs/TabPanel.js.map +1 -0
  7. package/lib/commonjs/tabs/TabsList.js +58 -0
  8. package/lib/commonjs/tabs/TabsList.js.map +1 -0
  9. package/lib/commonjs/tabs/TabsRoot.js +79 -0
  10. package/lib/commonjs/tabs/TabsRoot.js.map +1 -0
  11. package/lib/commonjs/tabs/context.js +19 -0
  12. package/lib/commonjs/tabs/context.js.map +1 -0
  13. package/lib/commonjs/tabs/index.js +47 -0
  14. package/lib/commonjs/tabs/index.js.map +1 -0
  15. package/lib/commonjs/tabs/types.js +6 -0
  16. package/lib/commonjs/tabs/types.js.map +1 -0
  17. package/lib/commonjs/tabs/useTabsNavigation.js +84 -0
  18. package/lib/commonjs/tabs/useTabsNavigation.js.map +1 -0
  19. package/lib/module/index.js +1 -0
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/tabs/Tab.js +118 -0
  22. package/lib/module/tabs/Tab.js.map +1 -0
  23. package/lib/module/tabs/TabPanel.js +49 -0
  24. package/lib/module/tabs/TabPanel.js.map +1 -0
  25. package/lib/module/tabs/TabsList.js +54 -0
  26. package/lib/module/tabs/TabsList.js.map +1 -0
  27. package/lib/module/tabs/TabsRoot.js +75 -0
  28. package/lib/module/tabs/TabsRoot.js.map +1 -0
  29. package/lib/module/tabs/context.js +11 -0
  30. package/lib/module/tabs/context.js.map +1 -0
  31. package/lib/module/tabs/index.js +8 -0
  32. package/lib/module/tabs/index.js.map +1 -0
  33. package/lib/module/tabs/types.js +4 -0
  34. package/lib/module/tabs/types.js.map +1 -0
  35. package/lib/module/tabs/useTabsNavigation.js +81 -0
  36. package/lib/module/tabs/useTabsNavigation.js.map +1 -0
  37. package/lib/typescript/index.d.ts +1 -0
  38. package/lib/typescript/index.d.ts.map +1 -1
  39. package/lib/typescript/tabs/Tab.d.ts +8 -0
  40. package/lib/typescript/tabs/Tab.d.ts.map +1 -0
  41. package/lib/typescript/tabs/TabPanel.d.ts +9 -0
  42. package/lib/typescript/tabs/TabPanel.d.ts.map +1 -0
  43. package/lib/typescript/tabs/TabsList.d.ts +8 -0
  44. package/lib/typescript/tabs/TabsList.d.ts.map +1 -0
  45. package/lib/typescript/tabs/TabsRoot.d.ts +8 -0
  46. package/lib/typescript/tabs/TabsRoot.d.ts.map +1 -0
  47. package/lib/typescript/tabs/context.d.ts +14 -0
  48. package/lib/typescript/tabs/context.d.ts.map +1 -0
  49. package/lib/typescript/tabs/index.d.ts +7 -0
  50. package/lib/typescript/tabs/index.d.ts.map +1 -0
  51. package/lib/typescript/tabs/types.d.ts +68 -0
  52. package/lib/typescript/tabs/types.d.ts.map +1 -0
  53. package/lib/typescript/tabs/useTabsNavigation.d.ts +14 -0
  54. package/lib/typescript/tabs/useTabsNavigation.d.ts.map +1 -0
  55. package/package.json +2 -2
  56. package/src/index.ts +1 -0
  57. package/src/tabs/Tab.tsx +158 -0
  58. package/src/tabs/TabPanel.tsx +52 -0
  59. package/src/tabs/TabsList.tsx +64 -0
  60. package/src/tabs/TabsRoot.tsx +115 -0
  61. package/src/tabs/context.tsx +10 -0
  62. package/src/tabs/index.ts +17 -0
  63. package/src/tabs/types.ts +73 -0
  64. package/src/tabs/useTabsNavigation.ts +97 -0
@@ -0,0 +1,14 @@
1
+ import type { RegisteredTab, TabsActivationMode, TabsOrientation } from './types';
2
+ export interface UseTabsNavigationOptions {
3
+ getTabs: () => [string, RegisteredTab][];
4
+ focusedValue: string | undefined;
5
+ setFocusedValue: (value: string | undefined) => void;
6
+ value: string | undefined;
7
+ onValueChange: (value: string) => void;
8
+ activationMode: TabsActivationMode;
9
+ orientation: TabsOrientation;
10
+ }
11
+ export declare function useTabsNavigation({ getTabs, focusedValue, setFocusedValue, value, onValueChange, activationMode, orientation, }: UseTabsNavigationOptions): {
12
+ handleKeyDown: (e: any) => void;
13
+ };
14
+ //# sourceMappingURL=useTabsNavigation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useTabsNavigation.d.ts","sourceRoot":"","sources":["../../../src/tabs/useTabsNavigation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAKlF,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,EAAE,CAAC;IACzC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,eAAe,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IACrD,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,cAAc,EAAE,kBAAkB,CAAC;IACnC,WAAW,EAAE,eAAe,CAAC;CAC9B;AAED,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,YAAY,EACZ,eAAe,EACf,KAAK,EACL,aAAa,EACb,cAAc,EACd,WAAW,GACZ,EAAE,wBAAwB;uBAqBnB,GAAG;EAmDV"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cdx-ui/primitives",
3
- "version": "0.0.1-beta.121",
3
+ "version": "0.0.1-beta.123",
4
4
  "main": "lib/commonjs/index.js",
5
5
  "module": "lib/module/index.js",
6
6
  "react-native": "src/index.ts",
@@ -60,7 +60,7 @@
60
60
  "@react-stately/checkbox": "3.7.4",
61
61
  "@react-stately/radio": "3.12.0",
62
62
  "@react-stately/toggle": "3.9.4",
63
- "@cdx-ui/utils": "0.0.1-beta.121"
63
+ "@cdx-ui/utils": "0.0.1-beta.123"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/react": "19.1.17",
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { type EdgeInsets, OverlayInsetsProvider } from './overlay';
14
14
  export { useAnchorPosition, type AnchorLayout } from './overlay';
15
15
  export * from './select';
16
16
  export * from './switch';
17
+ export * from './tabs';
17
18
  export * from './tile';
18
19
  export * from './toast';
19
20
  export * from './portal';
@@ -0,0 +1,158 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ type ComponentRef,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { Platform, Pressable } from 'react-native';
11
+ import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
12
+ import { useFocus, useFocusRing } from '@react-native-aria/focus';
13
+ import { useHover, usePress } from '@react-native-aria/interactions';
14
+ import { Slot } from '../slot';
15
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
16
+ import { TabProvider, useTabsContext } from './context';
17
+ import type { ITabProps } from './types';
18
+
19
+ export interface TabProps extends Omit<ITabProps, 'children'> {
20
+ asChild?: boolean;
21
+ children?: ReactNode;
22
+ }
23
+
24
+ export const Tab = forwardRef<ComponentRef<typeof Pressable>, TabProps>(
25
+ (
26
+ {
27
+ asChild,
28
+ children,
29
+ value: tabValue,
30
+ disabled: disabledProp = false,
31
+ isHovered: isHoveredProp,
32
+ isActive: isActiveProp,
33
+ isFocused: isFocusedProp,
34
+ isFocusVisible: isFocusVisibleProp,
35
+ isDisabled: isDisabledProp,
36
+ ...props
37
+ },
38
+ ref,
39
+ ) => {
40
+ const {
41
+ value: selectedValue,
42
+ onValueChange,
43
+ disabled: contextDisabled,
44
+ listId,
45
+ registerTab,
46
+ unregisterTab,
47
+ focusedValue,
48
+ setFocusedValue,
49
+ } = useTabsContext();
50
+
51
+ const disabled = contextDisabled || !!disabledProp || !!isDisabledProp;
52
+ const selected = selectedValue === tabValue;
53
+
54
+ const nodeRef = useRef<ComponentRef<typeof Pressable> | null>(null);
55
+ const mergedRef = mergeRefs(ref, nodeRef);
56
+
57
+ useEffect(() => {
58
+ registerTab(tabValue, {
59
+ disabled,
60
+ node: nodeRef.current as { focus?: () => void } | null,
61
+ });
62
+ return () => unregisterTab(tabValue);
63
+ }, [tabValue, disabled, registerTab, unregisterTab]);
64
+
65
+ const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
66
+ isFocusVisible: boolean;
67
+ focusProps: {
68
+ onFocus?: (...args: unknown[]) => unknown;
69
+ onBlur?: (...args: unknown[]) => unknown;
70
+ };
71
+ };
72
+ const { pressProps, isPressed: isActive } = usePress({ isDisabled: disabled });
73
+ const { isFocused, focusProps } = useFocus();
74
+ const { isHovered, hoverProps } = useHover();
75
+
76
+ const handlePress = useCallback(() => {
77
+ if (!disabled) {
78
+ setFocusedValue(tabValue);
79
+ onValueChange(tabValue);
80
+ }
81
+ }, [disabled, tabValue, onValueChange, setFocusedValue]);
82
+
83
+ const interactionState = useMemo(
84
+ () => ({
85
+ hover: !!(isHoveredProp || isHovered),
86
+ focus: !!(isFocusedProp || isFocused),
87
+ active: !!(isActiveProp || isActive),
88
+ disabled: !!disabled,
89
+ focusVisible: !!(isFocusVisibleProp || isFocusVisible),
90
+ selected,
91
+ }),
92
+ [
93
+ isHoveredProp,
94
+ isHovered,
95
+ isFocusedProp,
96
+ isFocused,
97
+ isActiveProp,
98
+ isActive,
99
+ disabled,
100
+ isFocusVisibleProp,
101
+ isFocusVisible,
102
+ selected,
103
+ ],
104
+ );
105
+
106
+ const isRovingTabStop = (focusedValue ?? selectedValue) === tabValue;
107
+ const webProps = Platform.OS === 'web' ? { tabIndex: isRovingTabStop ? 0 : -1 } : {};
108
+
109
+ const Comp = asChild ? Slot : Pressable;
110
+
111
+ return (
112
+ <TabProvider value={interactionState}>
113
+ <Comp
114
+ ref={mergedRef}
115
+ role="tab"
116
+ id={`${listId}-tab-${tabValue}`}
117
+ aria-selected={selected}
118
+ aria-controls={`${listId}-panel-${tabValue}`}
119
+ aria-disabled={disabled}
120
+ disabled={disabled}
121
+ accessibilityState={{ selected, disabled }}
122
+ {...webProps}
123
+ {...props}
124
+ {...mergeDataAttributes(
125
+ props,
126
+ dataAttributes({
127
+ hover: interactionState.hover,
128
+ focus: interactionState.focus,
129
+ active: interactionState.active,
130
+ disabled: interactionState.disabled,
131
+ focusVisible: interactionState.focusVisible,
132
+ state: selected ? 'active' : 'inactive',
133
+ }),
134
+ )}
135
+ onPress={composeEventHandlers(props?.onPress, handlePress)}
136
+ onPressIn={composeEventHandlers(props?.onPressIn, pressProps.onPressIn)}
137
+ onPressOut={composeEventHandlers(props?.onPressOut, pressProps.onPressOut)}
138
+ onHoverIn={composeEventHandlers(props?.onHoverIn, hoverProps.onHoverIn)}
139
+ onHoverOut={composeEventHandlers(props?.onHoverOut, hoverProps.onHoverOut)}
140
+ onFocus={composeEventHandlers(
141
+ props?.onFocus as (...args: unknown[]) => unknown,
142
+ focusProps.onFocus,
143
+ focusRingProps.onFocus,
144
+ )}
145
+ onBlur={composeEventHandlers(
146
+ props?.onBlur as (...args: unknown[]) => unknown,
147
+ focusProps.onBlur,
148
+ focusRingProps.onBlur,
149
+ )}
150
+ >
151
+ {children}
152
+ </Comp>
153
+ </TabProvider>
154
+ );
155
+ },
156
+ );
157
+
158
+ Tab.displayName = 'Tab';
@@ -0,0 +1,52 @@
1
+ import { forwardRef, type ComponentRef, type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+ import { Slot } from '../slot';
4
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
5
+ import { useTabsContext } from './context';
6
+ import type { ITabPanelProps } from './types';
7
+
8
+ export interface TabPanelProps extends Omit<ITabPanelProps, 'children'> {
9
+ asChild?: boolean;
10
+ children?: ReactNode;
11
+ }
12
+
13
+ export const TabPanel = forwardRef<ComponentRef<typeof View>, TabPanelProps>(
14
+ ({ asChild, children, value: panelValue, unmountWhenInactive = false, style, ...props }, ref) => {
15
+ const { value: selectedValue, listId } = useTabsContext();
16
+ const selected = selectedValue === panelValue;
17
+
18
+ if (!selected && unmountWhenInactive) {
19
+ return null;
20
+ }
21
+
22
+ const Comp = asChild ? Slot : View;
23
+
24
+ const hideFromAccessibility = selected
25
+ ? null
26
+ : {
27
+ accessibilityElementsHidden: true as const,
28
+ importantForAccessibility: 'no-hide-descendants' as const,
29
+ 'aria-hidden': true as const,
30
+ };
31
+
32
+ return (
33
+ <Comp
34
+ ref={ref}
35
+ role="tabpanel"
36
+ id={`${listId}-panel-${panelValue}`}
37
+ aria-labelledby={`${listId}-tab-${panelValue}`}
38
+ style={[!selected ? { display: 'none' } : null, style]}
39
+ {...props}
40
+ {...hideFromAccessibility}
41
+ {...mergeDataAttributes(
42
+ props,
43
+ dataAttributes({ slot: 'tabs-panel', state: selected ? 'active' : 'inactive' }),
44
+ )}
45
+ >
46
+ {children}
47
+ </Comp>
48
+ );
49
+ },
50
+ );
51
+
52
+ TabPanel.displayName = 'TabPanel';
@@ -0,0 +1,64 @@
1
+ import { forwardRef, type ComponentRef } from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import { composeEventHandlers } from '@cdx-ui/utils';
4
+ import { Slot } from '../slot';
5
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
6
+ import { useTabsContext } from './context';
7
+ import type { ITabsListProps } from './types';
8
+ import { useTabsNavigation } from './useTabsNavigation';
9
+
10
+ export interface TabsListProps extends ITabsListProps {
11
+ asChild?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ export const TabsList = forwardRef<ComponentRef<typeof View>, TabsListProps>(
16
+ ({ asChild, children, ...props }, ref) => {
17
+ const {
18
+ value,
19
+ onValueChange,
20
+ activationMode,
21
+ orientation,
22
+ getTabs,
23
+ focusedValue,
24
+ setFocusedValue,
25
+ } = useTabsContext();
26
+
27
+ const { handleKeyDown } = useTabsNavigation({
28
+ getTabs,
29
+ focusedValue,
30
+ setFocusedValue,
31
+ value,
32
+ onValueChange,
33
+ activationMode,
34
+ orientation,
35
+ });
36
+
37
+ const webProps =
38
+ Platform.OS === 'web'
39
+ ? {
40
+ onKeyDown: composeEventHandlers(
41
+ (props as { onKeyDown?: (e: KeyboardEvent) => void }).onKeyDown,
42
+ handleKeyDown,
43
+ ),
44
+ }
45
+ : {};
46
+
47
+ const Comp = asChild ? Slot : View;
48
+
49
+ return (
50
+ <Comp
51
+ ref={ref}
52
+ role="tablist"
53
+ aria-orientation={orientation}
54
+ {...props}
55
+ {...webProps}
56
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'tabs-list', orientation }))}
57
+ >
58
+ {children}
59
+ </Comp>
60
+ );
61
+ },
62
+ );
63
+
64
+ TabsList.displayName = 'TabsList';
@@ -0,0 +1,115 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ComponentRef,
10
+ } from 'react';
11
+ import { View, type ViewProps } from 'react-native';
12
+ import { useControllableState } from '@cdx-ui/utils';
13
+ import { Slot } from '../slot';
14
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
15
+ import { TabsProvider } from './context';
16
+ import type { ITabsProps, RegisteredTab } from './types';
17
+
18
+ export interface TabsRootProps extends ITabsProps, Omit<ViewProps, 'children'> {
19
+ asChild?: boolean;
20
+ className?: string;
21
+ }
22
+
23
+ export const TabsRoot = forwardRef<ComponentRef<typeof View>, TabsRootProps>(
24
+ (
25
+ {
26
+ asChild,
27
+ children,
28
+ value: valueProp,
29
+ defaultValue,
30
+ onValueChange,
31
+ activationMode = 'automatic',
32
+ orientation = 'horizontal',
33
+ disabled = false,
34
+ ...props
35
+ },
36
+ ref,
37
+ ) => {
38
+ const [value, setValue] = useControllableState<string | undefined>({
39
+ prop: valueProp,
40
+ defaultProp: defaultValue,
41
+ onChange: (newValue) => {
42
+ if (newValue !== undefined) {
43
+ onValueChange?.(newValue);
44
+ }
45
+ },
46
+ });
47
+
48
+ const [focusedValue, setFocusedValue] = useState<string | undefined>(undefined);
49
+
50
+ // When the parent updates the controlled selection, drop stale roving-focus state so
51
+ // tabIndex and arrow-key navigation follow `value` instead of a previously focused tab.
52
+ useEffect(() => {
53
+ setFocusedValue((prev) => (prev !== undefined && prev !== value ? undefined : prev));
54
+ }, [value]);
55
+
56
+ const tabsRef = useRef<Map<string, RegisteredTab>>(new Map());
57
+
58
+ const registerTab = useCallback((tabValue: string, entry: RegisteredTab) => {
59
+ tabsRef.current.set(tabValue, entry);
60
+ }, []);
61
+
62
+ const unregisterTab = useCallback((tabValue: string) => {
63
+ tabsRef.current.delete(tabValue);
64
+ }, []);
65
+
66
+ const getTabs = useCallback(() => Array.from(tabsRef.current.entries()), []);
67
+
68
+ const reactId = useId();
69
+ const listId = `tabs-${reactId}`;
70
+
71
+ const contextValue = useMemo(
72
+ () => ({
73
+ value,
74
+ onValueChange: setValue,
75
+ disabled,
76
+ activationMode,
77
+ orientation,
78
+ listId,
79
+ registerTab,
80
+ unregisterTab,
81
+ getTabs,
82
+ focusedValue,
83
+ setFocusedValue,
84
+ }),
85
+ [
86
+ value,
87
+ setValue,
88
+ disabled,
89
+ activationMode,
90
+ orientation,
91
+ listId,
92
+ registerTab,
93
+ unregisterTab,
94
+ getTabs,
95
+ focusedValue,
96
+ ],
97
+ );
98
+
99
+ const Comp = asChild ? Slot : View;
100
+
101
+ return (
102
+ <TabsProvider value={contextValue}>
103
+ <Comp
104
+ ref={ref}
105
+ {...props}
106
+ {...mergeDataAttributes(props, dataAttributes({ disabled, orientation }))}
107
+ >
108
+ {children}
109
+ </Comp>
110
+ </TabsProvider>
111
+ );
112
+ },
113
+ );
114
+
115
+ TabsRoot.displayName = 'TabsRoot';
@@ -0,0 +1,10 @@
1
+ import { createContext } from '@cdx-ui/utils';
2
+ import type { TabInteractionState, TabsContextValue } from './types';
3
+
4
+ export const [TabsProvider, useTabsContext] = createContext<TabsContextValue>('TabsContext');
5
+
6
+ /**
7
+ * Propagates a tab's interaction and selection state (hover, focus, active, selected, etc.)
8
+ * to child slots like a styled `Tabs.Tab`'s label/icon.
9
+ */
10
+ export const [TabProvider, useTabContext] = createContext<TabInteractionState>('TabContext');
@@ -0,0 +1,17 @@
1
+ export type {
2
+ ITabPanelProps,
3
+ ITabProps,
4
+ ITabsListProps,
5
+ ITabsProps,
6
+ RegisteredTab,
7
+ TabInteractionState,
8
+ TabsActivationMode,
9
+ TabsContextValue,
10
+ TabsOrientation,
11
+ } from './types';
12
+
13
+ export { useTabContext, useTabsContext } from './context';
14
+ export { TabsRoot, type TabsRootProps } from './TabsRoot';
15
+ export { TabsList, type TabsListProps } from './TabsList';
16
+ export { Tab, type TabProps } from './Tab';
17
+ export { TabPanel, type TabPanelProps } from './TabPanel';
@@ -0,0 +1,73 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PressableProps, ViewProps } from 'react-native';
3
+ import type { InteractionState } from '../types';
4
+
5
+ export type TabsActivationMode = 'automatic' | 'manual';
6
+ export type TabsOrientation = 'horizontal' | 'vertical';
7
+
8
+ export interface ITabsProps {
9
+ /** Controlled selected tab value. */
10
+ value?: string;
11
+ /** Default selected tab value for uncontrolled usage. */
12
+ defaultValue?: string;
13
+ onValueChange?: (value: string) => void;
14
+ /**
15
+ * `automatic` (default) selects a tab as soon as it receives focus via arrow keys.
16
+ * `manual` only moves focus on arrow keys — the consumer must press Enter/Space to select.
17
+ */
18
+ activationMode?: TabsActivationMode;
19
+ /**
20
+ * Layout axis and roving-focus key set. `horizontal` (default) uses ArrowLeft/ArrowRight;
21
+ * `vertical` uses ArrowUp/ArrowDown, per the ARIA APG tabs pattern.
22
+ */
23
+ orientation?: TabsOrientation;
24
+ disabled?: boolean;
25
+ children?: ReactNode;
26
+ }
27
+
28
+ export interface ITabsListProps extends ViewProps {
29
+ children?: ReactNode;
30
+ }
31
+
32
+ export interface ITabProps extends PressableProps {
33
+ /** Required — correlates this tab with its `Tabs.Panel` counterpart. */
34
+ value: string;
35
+ disabled?: boolean;
36
+ isHovered?: boolean;
37
+ isActive?: boolean;
38
+ isFocused?: boolean;
39
+ isFocusVisible?: boolean;
40
+ isDisabled?: boolean;
41
+ }
42
+
43
+ export interface ITabPanelProps extends ViewProps {
44
+ /** Required — correlates this panel with its `Tabs.Tab` counterpart. */
45
+ value: string;
46
+ /** Unmount when inactive instead of keeping the panel hidden in the DOM. */
47
+ unmountWhenInactive?: boolean;
48
+ children?: ReactNode;
49
+ }
50
+
51
+ export interface TabInteractionState extends InteractionState {
52
+ selected: boolean;
53
+ }
54
+
55
+ export interface RegisteredTab {
56
+ disabled: boolean;
57
+ node: { focus?: () => void } | null;
58
+ }
59
+
60
+ export interface TabsContextValue {
61
+ value: string | undefined;
62
+ onValueChange: (value: string) => void;
63
+ disabled: boolean;
64
+ activationMode: TabsActivationMode;
65
+ orientation: TabsOrientation;
66
+ listId: string;
67
+ registerTab: (value: string, entry: RegisteredTab) => void;
68
+ unregisterTab: (value: string) => void;
69
+ getTabs: () => [string, RegisteredTab][];
70
+ /** Currently roving-focused tab value (keyboard navigation), distinct from committed `value`. */
71
+ focusedValue: string | undefined;
72
+ setFocusedValue: (value: string | undefined) => void;
73
+ }
@@ -0,0 +1,97 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import type { RegisteredTab, TabsActivationMode, TabsOrientation } from './types';
3
+
4
+ // Web-only — native platforms rely on OS-managed sequential focus (VoiceOver/TalkBack),
5
+ // same restriction documented on `useListboxNavigation` in `../select/`.
6
+
7
+ export interface UseTabsNavigationOptions {
8
+ getTabs: () => [string, RegisteredTab][];
9
+ focusedValue: string | undefined;
10
+ setFocusedValue: (value: string | undefined) => void;
11
+ value: string | undefined;
12
+ onValueChange: (value: string) => void;
13
+ activationMode: TabsActivationMode;
14
+ orientation: TabsOrientation;
15
+ }
16
+
17
+ export function useTabsNavigation({
18
+ getTabs,
19
+ focusedValue,
20
+ setFocusedValue,
21
+ value,
22
+ onValueChange,
23
+ activationMode,
24
+ orientation,
25
+ }: UseTabsNavigationOptions) {
26
+ const onValueChangeRef = useRef(onValueChange);
27
+ onValueChangeRef.current = onValueChange;
28
+
29
+ const getEnabledTabs = useCallback(
30
+ () => getTabs().filter(([, entry]) => !entry.disabled),
31
+ [getTabs],
32
+ );
33
+
34
+ const moveFocus = useCallback(
35
+ ([targetValue, targetEntry]: [string, RegisteredTab]) => {
36
+ setFocusedValue(targetValue);
37
+ targetEntry.node?.focus?.();
38
+ if (activationMode === 'automatic') {
39
+ onValueChangeRef.current(targetValue);
40
+ }
41
+ },
42
+ [setFocusedValue, activationMode],
43
+ );
44
+
45
+ const handleKeyDown = useCallback(
46
+ (e: any) => {
47
+ const enabledTabs = getEnabledTabs();
48
+ if (enabledTabs.length === 0) {
49
+ return;
50
+ }
51
+
52
+ const currentValue = focusedValue ?? value;
53
+ const idx = enabledTabs.findIndex(([tabValue]) => tabValue === currentValue);
54
+
55
+ // ARIA APG tabs pattern: horizontal lists respond to Left/Right, vertical lists to Up/Down.
56
+ const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
57
+ const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
58
+
59
+ switch (e.key) {
60
+ case nextKey: {
61
+ e.preventDefault();
62
+ moveFocus(enabledTabs[idx < enabledTabs.length - 1 ? idx + 1 : 0]);
63
+ break;
64
+ }
65
+ case prevKey: {
66
+ e.preventDefault();
67
+ moveFocus(enabledTabs[idx > 0 ? idx - 1 : enabledTabs.length - 1]);
68
+ break;
69
+ }
70
+ case 'Home': {
71
+ e.preventDefault();
72
+ moveFocus(enabledTabs[0]);
73
+ break;
74
+ }
75
+ case 'End': {
76
+ e.preventDefault();
77
+ const last = enabledTabs.at(-1);
78
+ if (last) {
79
+ moveFocus(last);
80
+ }
81
+ break;
82
+ }
83
+ case 'Enter':
84
+ case ' ': {
85
+ if (activationMode === 'manual' && focusedValue) {
86
+ e.preventDefault();
87
+ onValueChangeRef.current(focusedValue);
88
+ }
89
+ break;
90
+ }
91
+ }
92
+ },
93
+ [focusedValue, value, getEnabledTabs, moveFocus, activationMode, orientation],
94
+ );
95
+
96
+ return { handleKeyDown };
97
+ }