Skip to content

Commit a23d708

Browse files
committed
fix(tabs): Scroll tabs correctly in RTL mode
This also handled some tech-debt of updating the Tab components to use the new keyboard movement API. Closes #1356
1 parent 71d1343 commit a23d708

File tree

12 files changed

+653
-371
lines changed

12 files changed

+653
-371
lines changed

packages/tabs/src/Tab.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import cn from "classnames";
44
import { TextIconSpacing } from "@react-md/icon";
55
import type { InteractionStatesOptions } from "@react-md/states";
66
import { useInteractionStates } from "@react-md/states";
7-
import { bem, useResizeObserver } from "@react-md/utils";
7+
import { bem, useKeyboardFocusableElement } from "@react-md/utils";
88

99
import type { TabConfig } from "./types";
10-
import { useUpdateIndicatorStyles } from "./useTabIndicatorStyle";
1110

1211
export interface TabProps
1312
extends TabConfig,
@@ -65,7 +64,7 @@ export const Tab = forwardRef<HTMLButtonElement, TabProps>(function Tab(
6564
enablePressedAndRipple,
6665
...props
6766
},
68-
propRef
67+
ref
6968
) {
7069
const { ripples, className, handlers } = useInteractionStates({
7170
handlers: props,
@@ -79,19 +78,13 @@ export const Tab = forwardRef<HTMLButtonElement, TabProps>(function Tab(
7978
rippleContainerClassName,
8079
enablePressedAndRipple,
8180
});
82-
// TODO: Look into removing this resize observer. This is only required if
83-
// someone manually updates the width of the tab (dev utils) or if the width
84-
// was not changed due to the tabs container element resizing (iffy)
85-
const updateIndicatorStyles = useUpdateIndicatorStyles();
86-
const [, refHandler] = useResizeObserver(updateIndicatorStyles, {
87-
ref: propRef,
88-
});
81+
const refCallback = useKeyboardFocusableElement(ref);
8982

9083
return (
9184
<button
9285
{...props}
9386
{...handlers}
94-
ref={active ? refHandler : propRef}
87+
ref={refCallback}
9588
aria-selected={active}
9689
aria-controls={panelId}
9790
type="button"

packages/tabs/src/TabPanels.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ export interface TabPanelsProps extends HTMLAttributes<HTMLDivElement> {
1919
/**
2020
* Boolean if this component should no longer automatically reset the scrolling
2121
* to the top when the panel changes.
22+
*
23+
* @defaultValue `false`
2224
*/
2325
disableScrollFix?: boolean;
2426

2527
/**
2628
* Boolean if the swiping transition should be disabled. If you want to add
2729
* a custom transition, you'll need to wrap the `TabPanel`'s children in a
2830
* custom component that does appear and exit animations.
31+
*
32+
* @defaultValue `false`
2933
*/
3034
disableTransition?: boolean;
3135

@@ -35,6 +39,8 @@ export interface TabPanelsProps extends HTMLAttributes<HTMLDivElement> {
3539
* instead of mounting and unmounting when their active state changes. The
3640
* panels will also be updated to ensure that inactive panels can not be
3741
* tab focusable.
42+
*
43+
* @defaultValue `false`
3844
*/
3945
persistent?: boolean;
4046
}

packages/tabs/src/Tabs.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { forwardRef } from "react";
2+
import { KeyboardMovementProvider } from "@react-md/utils";
23

34
import { Tab } from "./Tab";
45
import type { TabsListProps } from "./TabsList";
@@ -19,17 +20,29 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(function Tabs(
1920
ref
2021
) {
2122
const { tabsId, tabs, activeIndex, onActiveIndexChange } = useTabs();
23+
const horizontal = props.orientation !== "vertical";
24+
2225
return (
23-
<TabsList
24-
{...props}
25-
id={tabsId}
26-
ref={ref}
27-
activeIndex={activeIndex}
28-
onActiveIndexChange={onActiveIndexChange}
29-
>
30-
{tabs.map(({ id, ...config }, index) => (
31-
<Tab {...config} id={id} key={id} active={activeIndex === index} />
32-
))}
33-
</TabsList>
26+
<KeyboardMovementProvider loopable horizontal={horizontal}>
27+
<TabsList
28+
{...props}
29+
id={tabsId}
30+
ref={ref}
31+
activeIndex={activeIndex}
32+
onActiveIndexChange={onActiveIndexChange}
33+
>
34+
{tabs.map(({ id, ...config }, index) => (
35+
<Tab
36+
{...config}
37+
id={id}
38+
key={id}
39+
active={activeIndex === index}
40+
onClick={() => {
41+
onActiveIndexChange(index);
42+
}}
43+
/>
44+
))}
45+
</TabsList>
46+
</KeyboardMovementProvider>
3447
);
3548
});

packages/tabs/src/TabsList.tsx

Lines changed: 44 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
1-
/* eslint-disable jsx-a11y/interactive-supports-focus */
2-
import type { HTMLAttributes, Ref } from "react";
1+
import type { HTMLAttributes } from "react";
2+
import { forwardRef } from "react";
33
import {
4-
Children,
5-
cloneElement,
6-
forwardRef,
7-
isValidElement,
8-
useEffect,
9-
useRef,
10-
} from "react";
4+
bem,
5+
useIsUserInteractionMode,
6+
useKeyboardFocus,
7+
} from "@react-md/utils";
118
import cn from "classnames";
12-
import { applyRef, bem, useIsUserInteractionMode } from "@react-md/utils";
139

1410
import type { TabsConfig } from "./types";
15-
import {
16-
UpdateIndicatorStylesProvider,
17-
useTabIndicatorStyle,
18-
} from "./useTabIndicatorStyle";
19-
import { useTabsMovement } from "./useTabsMovement";
11+
import { useTabIndicatorStyles } from "./useTabIndicatorStyles";
2012

2113
export interface TabsListProps
2214
extends HTMLAttributes<HTMLDivElement>,
@@ -36,11 +28,13 @@ export interface TabsListProps
3628
/**
3729
* Boolean if the indicator transition should be disabled while the active tab
3830
* index changes.
31+
*
32+
* @defaultValue `false`
3933
*/
4034
disableTransition?: boolean;
4135
}
4236

43-
const block = bem("rmd-tabs");
37+
const styles = bem("rmd-tabs");
4438

4539
/**
4640
* The `TabsList` component is the container for all the individual `Tab`s that
@@ -57,7 +51,7 @@ export const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
5751
{
5852
style,
5953
className,
60-
onClick,
54+
onFocus,
6155
onKeyDown,
6256
children,
6357
activeIndex,
@@ -69,91 +63,46 @@ export const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
6963
disableTransition = false,
7064
...props
7165
},
72-
forwardedRef
66+
ref
7367
) {
7468
const horizontal = orientation === "horizontal";
75-
const { tabs, itemRefs, handleClick, handleKeyDown } = useTabsMovement({
76-
onClick,
69+
const isKeyboard = useIsUserInteractionMode("keyboard");
70+
const { focusIndex: _focusIndex, ...eventHandlers } = useKeyboardFocus({
71+
onFocus,
7772
onKeyDown,
78-
children,
79-
horizontal,
80-
activeIndex,
81-
onActiveIndexChange,
82-
automatic,
73+
onFocusChange(element, focusIndex) {
74+
element.focus();
75+
if (automatic) {
76+
onActiveIndexChange(focusIndex);
77+
}
78+
},
8379
});
84-
const [mergedStyle, tabsRefHandler, tabsRef, updateIndicatorStyles] =
85-
useTabIndicatorStyle({
86-
style,
87-
ref: forwardedRef,
88-
align,
89-
itemRefs,
90-
totalTabs: tabs.length,
91-
activeIndex,
92-
});
93-
const isKeyboard = useIsUserInteractionMode("keyboard");
9480

95-
const prevActiveIndex = useRef(activeIndex);
96-
useEffect(() => {
97-
const tabs = tabsRef.current;
98-
const tabRef = itemRefs[activeIndex] && itemRefs[activeIndex].current;
99-
const incrementing = prevActiveIndex.current < activeIndex;
100-
prevActiveIndex.current = activeIndex;
101-
if (!tabs || !tabRef) {
102-
return;
103-
}
104-
105-
const currentX = tabs.scrollLeft + tabs.offsetWidth;
106-
const tabLeft = tabRef.offsetLeft;
107-
const tabWidth = tabRef.offsetWidth;
108-
if (incrementing && currentX < tabLeft + tabWidth) {
109-
tabs.scrollLeft = tabLeft - tabWidth;
110-
} else if (!incrementing && tabs.scrollLeft > tabLeft) {
111-
tabs.scrollLeft = tabLeft;
112-
}
113-
114-
// don't want this to trigger on itemRefs or tabsRef changes since those
115-
// have a chance of updating each render.
116-
// eslint-disable-next-line react-hooks/exhaustive-deps
117-
}, [activeIndex]);
81+
const { refCallback, indicatorStyles } = useTabIndicatorStyles({
82+
ref,
83+
activeIndex,
84+
});
11885

11986
return (
120-
<UpdateIndicatorStylesProvider value={updateIndicatorStyles}>
121-
<div
122-
{...props}
123-
aria-orientation={orientation}
124-
style={mergedStyle}
125-
role="tablist"
126-
className={cn(
127-
block({
128-
[align]: true,
129-
padded,
130-
vertical: !horizontal,
131-
animate: !disableTransition && (!automatic || !isKeyboard),
132-
}),
133-
className
134-
)}
135-
ref={tabsRefHandler}
136-
onClick={handleClick}
137-
onKeyDown={handleKeyDown}
138-
>
139-
{Children.map(tabs, (child, i) => {
140-
if (!isValidElement(child)) {
141-
return child;
142-
}
143-
144-
const tab = Children.only(child);
145-
let ref: Ref<HTMLElement> = itemRefs[i];
146-
if (tab.props.ref) {
147-
ref = (instance: HTMLElement | null) => {
148-
itemRefs[i].current = instance;
149-
applyRef(instance, tab.props.ref);
150-
};
151-
}
152-
153-
return cloneElement(tab, { ref });
154-
})}
155-
</div>
156-
</UpdateIndicatorStylesProvider>
87+
<div
88+
{...props}
89+
aria-orientation={orientation}
90+
style={{ ...style, ...indicatorStyles }}
91+
role="tablist"
92+
ref={refCallback}
93+
className={cn(
94+
styles({
95+
[align]: true,
96+
padded,
97+
vertical: !horizontal,
98+
animate: !disableTransition && (!automatic || !isKeyboard),
99+
}),
100+
className
101+
)}
102+
{...eventHandlers}
103+
>
104+
{children}
105+
</div>
157106
);
158107
}
159108
);

packages/tabs/src/TabsManager.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface TabsManagerContext {
3939
* A function to call when the `activeIndex` should change due to keyboard
4040
* movement or clicking on a tab.
4141
*/
42-
onActiveIndexChange: (activeIndex: number) => void;
42+
onActiveIndexChange(activeIndex: number): void;
4343

4444
/**
4545
* The list of tabs that should be controlled by the tabs manager.
@@ -76,6 +76,8 @@ export interface TabsManagerProps
7676
/**
7777
* The index of the tab that should be active by default. This is ignored if
7878
* the `activeIndex` prop is defined.
79+
*
80+
* @defaultValue `0`
7981
*/
8082
defaultActiveIndex?: number;
8183

@@ -114,6 +116,8 @@ export interface TabsManagerProps
114116
* it for each tab in the `tabs` list and if a `tab` in the `tabs` list has
115117
* the `stacked` attribute enabled defined, it will be used instead of this
116118
* value.
119+
*
120+
* @defaultValue `false`
117121
*/
118122
stacked?: boolean;
119123

@@ -126,6 +130,8 @@ export interface TabsManagerProps
126130
* it for each tab in the `tabs` list and if a `tab` in the `tabs` list has
127131
* the `stacked` attribute enabled defined, it will be used instead of this
128132
* value.
133+
*
134+
* @defaultValue `false`
129135
*/
130136
iconAfter?: boolean;
131137
}

packages/tabs/src/__tests__/Tab.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { KeyboardMovementProvider } from "@react-md/utils";
12
import { render } from "@testing-library/react";
23

34
import { Tab } from "../Tab";
45

56
describe("Tab", () => {
67
it("should render correctly", () => {
78
const props = { id: "tab", active: false, children: "Tab" };
8-
const { container, rerender } = render(<Tab {...props} />);
9+
const { container, rerender } = render(<Tab {...props} />, {
10+
wrapper: KeyboardMovementProvider,
11+
});
912

1013
expect(container).toMatchSnapshot();
1114

0 commit comments

Comments
 (0)