Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/components/src/components/Tabs/Tabs.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@
.horizontal {
flex-direction: column;

&[data-horizontal-scrollable] {
.scrollBox {
cursor: grab;

&[data-dragging] {
cursor: grabbing;
user-select: none;
}
}
}

.scrollBox {
display: flex;
inline-size: 100%;
Expand Down
265 changes: 265 additions & 0 deletions packages/components/src/components/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,29 @@ describe('Tabs', () => {
const hasNextScrollButton = (container: HTMLElement) =>
!!findScrollButton(container, ariaLabelNextBtn);

const firePointerEvent = (
element: Element,
type: string,
{
pointerId,
pointerType,
...eventInit
}: MouseEventInit & { pointerId: number; pointerType: string }
) => {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
...eventInit,
});

Object.defineProperties(event, {
pointerId: { value: pointerId },
pointerType: { value: pointerType },
});

fireEvent(element, event);
};

// Mock scrollTo method (jsdom doesn't support scrolling)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
value(options?: ScrollToOptions | number, y?: number) {
Expand Down Expand Up @@ -494,6 +517,248 @@ describe('Tabs', () => {

expect(Number(scrollBox?.scrollLeft) < 100).toBeTruthy();
});

it('should drag horizontal tabs with the primary mouse button', () => {
const { container } = render(renderComponent({}));

const scrollBox = container.querySelector(
`.${s.scrollBox}`
) as HTMLElement;

Object.defineProperty(scrollBox, 'scrollLeft', {
value: 100,
configurable: true,
writable: true,
});

Object.defineProperty(scrollBox, 'clientWidth', { value: 100 });
Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 });
fireEvent.scroll(scrollBox);

firePointerEvent(scrollBox, 'pointerdown', {
pointerId: 1,
pointerType: 'mouse',
button: 0,
clientX: 100,
});

firePointerEvent(scrollBox, 'pointermove', {
pointerId: 1,
pointerType: 'mouse',
clientX: 60,
});

expect(scrollBox.scrollLeft).toBe(140);
expect(scrollBox).toHaveAttribute('data-dragging', 'true');

firePointerEvent(scrollBox, 'pointercancel', {
pointerId: 1,
pointerType: 'mouse',
});

expect(scrollBox).not.toHaveAttribute('data-dragging');
});

it('should not drag non-overflowing, vertical, or touch tabs', () => {
const { container, rerender } = render(
renderComponent({ orientation: 'vertical' })
);

let scrollBox = container.querySelector(`.${s.scrollBox}`) as HTMLElement;

Object.defineProperty(scrollBox, 'scrollLeft', {
value: 100,
configurable: true,
writable: true,
});

firePointerEvent(scrollBox, 'pointerdown', {
pointerId: 1,
pointerType: 'mouse',
button: 0,
clientX: 100,
});

firePointerEvent(scrollBox, 'pointermove', {
pointerId: 1,
pointerType: 'mouse',
clientX: 60,
});

expect(scrollBox.scrollLeft).toBe(100);

scrollBox.scrollLeft = 0;
rerender(renderComponent({}));

scrollBox = container.querySelector(`.${s.scrollBox}`) as HTMLElement;

firePointerEvent(scrollBox, 'pointerdown', {
pointerId: 2,
pointerType: 'mouse',
button: 0,
clientX: 100,
});

firePointerEvent(scrollBox, 'pointermove', {
pointerId: 2,
pointerType: 'mouse',
clientX: 60,
});

expect(scrollBox.scrollLeft).toBe(0);

Object.defineProperty(scrollBox, 'clientWidth', { value: 100 });
Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 });
scrollBox.scrollLeft = 100;
fireEvent.scroll(scrollBox);

firePointerEvent(scrollBox, 'pointerdown', {
pointerId: 3,
pointerType: 'touch',
button: 0,
clientX: 100,
});

firePointerEvent(scrollBox, 'pointermove', {
pointerId: 3,
pointerType: 'touch',
clientX: 60,
});

expect(scrollBox.scrollLeft).toBe(100);
});

it('should not select a tab when its pointer interaction becomes a drag', () => {
const onSelectionChange = vi.fn();

const { container } = render(
renderComponent({ selectedKey: 1, onSelectionChange })
);

const scrollBox = container.querySelector(
`.${s.scrollBox}`
) as HTMLElement;

const tab = screen.getByTestId(TAB__TEST_ID);

Object.defineProperty(scrollBox, 'clientWidth', { value: 100 });
Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 });
fireEvent.scroll(scrollBox);

firePointerEvent(tab, 'pointerdown', {
pointerId: 1,
pointerType: 'mouse',
button: 0,
clientX: 100,
});

// React Aria uses mouse events as a PointerEvent fallback in JSDOM.
fireEvent.mouseDown(tab, { button: 0, clientX: 100 });

expect(onSelectionChange).not.toHaveBeenCalled();

firePointerEvent(scrollBox, 'pointermove', {
pointerId: 1,
pointerType: 'mouse',
clientX: 60,
});

firePointerEvent(scrollBox, 'pointerup', {
pointerId: 1,
pointerType: 'mouse',
button: 0,
clientX: 60,
});

fireEvent.mouseUp(scrollBox, { button: 0, clientX: 60 });
fireEvent.click(tab);

expect(onSelectionChange).not.toHaveBeenCalled();
});

it('should select an overflowed tab when a mouse press is released without dragging', () => {
const onSelectionChange = vi.fn();

const { container } = render(
renderComponent({ selectedKey: 1, onSelectionChange })
);

const scrollBox = container.querySelector(
`.${s.scrollBox}`
) as HTMLElement;

const tab = screen.getByTestId(TAB__TEST_ID);

Object.defineProperty(scrollBox, 'clientWidth', { value: 100 });
Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 });
fireEvent.scroll(scrollBox);

fireEvent.mouseDown(tab, { button: 0 });

expect(onSelectionChange).not.toHaveBeenCalled();

fireEvent.mouseUp(tab, { button: 0 });
fireEvent.click(tab);

expect(onSelectionChange).toHaveBeenCalledOnce();
expect(onSelectionChange).toHaveBeenCalledWith('2');
});

it('should keep scrolling with inertia after mouse release', () => {
let frameCallback: FrameRequestCallback | undefined;

const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
frameCallback = callback;

return 1;
});

const { container } = render(renderComponent({}));

const scrollBox = container.querySelector(
`.${s.scrollBox}`
) as HTMLElement;

Object.defineProperty(scrollBox, 'scrollLeft', {
value: 100,
configurable: true,
writable: true,
});

Object.defineProperty(scrollBox, 'clientWidth', { value: 100 });
Object.defineProperty(scrollBox, 'scrollWidth', { value: 300 });
fireEvent.scroll(scrollBox);

firePointerEvent(scrollBox, 'pointerdown', {
pointerId: 1,
pointerType: 'mouse',
button: 0,
clientX: 100,
});

firePointerEvent(scrollBox, 'pointermove', {
pointerId: 1,
pointerType: 'mouse',
clientX: 60,
});

firePointerEvent(scrollBox, 'pointerup', {
pointerId: 1,
pointerType: 'mouse',
button: 0,
clientX: 60,
});

const scrollLeftAfterDrag = scrollBox.scrollLeft;

act(() => frameCallback?.(16));

expect(scrollBox.scrollLeft).toBeGreaterThan(scrollLeftAfterDrag);

requestAnimationFrameSpy.mockRestore();
});
});
});

Expand Down
10 changes: 10 additions & 0 deletions packages/components/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
TabPanel,
TabScrollButton,
} from './components';
import { useDragScroll } from './hooks';
import intlMessages from './intl.json';
import type { TabProps as TabItemProps } from './Tab';
import s from './Tabs.module.css';
Expand Down Expand Up @@ -107,6 +108,8 @@ export function TabsRender<T extends object>(
const el = scrollBoxRef.current;
if (!el) return;

cancelInertia();

if (orientation === 'horizontal') {
el.scrollTo({ left: value, behavior });
} else {
Expand Down Expand Up @@ -160,6 +163,10 @@ export function TabsRender<T extends object>(
const hasVerticalOverflow =
!isHorizontal && (verticalOverflow.start || verticalOverflow.end);

const { isDragging, cancelInertia, dragScrollProps } = useDragScroll(
hasHorizontalOverflow
);

const activeHorizontalOverflow = isHorizontal
? horizontalOverflow
: { start: false, end: false };
Expand Down Expand Up @@ -280,9 +287,11 @@ export function TabsRender<T extends object>(

const scrollBoxProps = mergeProps(
{
...dragScrollProps,
ref: scrollBoxRef,
className: s.scrollBox,
onScroll: updateScrollState,
'data-dragging': isDragging || undefined,
'data-overflow-inline-start': activeHorizontalOverflow.start || undefined,
'data-overflow-inline-end': activeHorizontalOverflow.end || undefined,
'data-overflow-block-start': activeVerticalOverflow.start || undefined,
Expand Down Expand Up @@ -339,6 +348,7 @@ export function TabsRender<T extends object>(
orientation={orientation}
isStretched={isStretched}
isUnderlined={isUnderlined}
shouldSelectOnPressUp={hasHorizontalOverflow}
closeButtonProps={slotProps?.closeButton}
{...(onRemove &&
typeof onRemove === 'function' && {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type TabProps<T> = {
orientation: TabOrientation;
isUnderlined?: boolean;
isStretched?: boolean;
shouldSelectOnPressUp?: boolean;
onFocused?: () => void;
onRemove?: () => void;
closeButtonProps?: IconButtonProps;
Expand All @@ -40,6 +41,7 @@ export function Tab<T>({
orientation,
isUnderlined = false,
isStretched = false,
shouldSelectOnPressUp = false,
onFocused,
onRemove,
closeButtonProps,
Expand All @@ -50,7 +52,12 @@ export function Tab<T>({
const allowsRemoving = !!onRemove;

const domRef = useDOMRef<HTMLElement>(innerRef);
const { tabProps, isSelected, isDisabled } = useTab({ key }, state, domRef);

const { tabProps, isSelected, isDisabled } = useTab(
{ key, shouldSelectOnPressUp },
state,
domRef
);

const {
href,
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/components/Tabs/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useDragScroll';
Loading
Loading