Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Add tabs scroll snapping #2899

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
8 changes: 4 additions & 4 deletions pages/tabs/responsive-integ.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ export default function TabsDemoPage() {
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
},
{
label: 'Third tab',
label: 'Third tab with longer title',
id: 'third',
content:
'Diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
dismissible: true,
dismissLabel: 'Dismiss third tab',
},
{
label: 'Fourth tab',
label: 'Fourth tab with some',
id: 'fourth',
content:
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
Expand All @@ -43,7 +43,7 @@ export default function TabsDemoPage() {
'Diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
},
{
label: 'Sixth tab',
label: 'Sixth tab with actions',
id: 'sixth',
content:
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
Expand Down Expand Up @@ -123,7 +123,7 @@ export default function TabsDemoPage() {
]);

const extraTab: TabsProps.Tab = {
label: 'Seventh tab',
label: 'New Seventh tab',
id: 'seventh',
content:
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
Expand Down
30 changes: 0 additions & 30 deletions src/tabs/__tests__/smooth-scroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { render } from '@testing-library/react';
import { waitFor } from '@testing-library/react';

import { isMotionDisabled } from '@cloudscape-design/component-toolkit/internal';

import nativeSupport from '../../../lib/components/tabs/native-smooth-scroll-supported';
import smoothScroll from '../../../lib/components/tabs/smooth-scroll';
import createWrapper from '../../../lib/components/test-utils/dom';

jest.mock('../../../lib/components/tabs/native-smooth-scroll-supported', () => {
return jest.fn();
});
jest.mock('@cloudscape-design/component-toolkit/internal', () => ({
...jest.requireActual('@cloudscape-design/component-toolkit/internal'),
isMotionDisabled: jest.fn(),
Expand All @@ -31,42 +26,17 @@ function renderScrollableElement(): HTMLElement {
return createWrapper(renderResult.container).findByClassName('scrollable')!.getElement();
}

async function usesCustomScrollingFunction(element: HTMLElement, scrollLeft: number) {
expect(nativeScrollMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(element.scrollLeft).toEqual(scrollLeft);
});
}

beforeEach(() => {
(nativeSupport as jest.Mock).mockReturnValue(false);
(isMotionDisabled as jest.Mock).mockReturnValue(false);
nativeScrollMock.mockClear();
});

describe('Smooth scroll', () => {
test('uses native scrollTo function if the browser supports it', () => {
(nativeSupport as jest.Mock).mockReturnValue(true);
const element = renderScrollableElement();
smoothScroll(element, 100);
expect(nativeScrollMock).toHaveBeenCalled();
});
test('relies on custom function when browsers do not support it', async () => {
const element = renderScrollableElement();
smoothScroll(element, 100);
await usesCustomScrollingFunction(element, 100);
});
test('does not animate when motion is disabled', () => {
(isMotionDisabled as jest.Mock).mockReturnValue(true);
const element = renderScrollableElement();
smoothScroll(element, 100);
expect(nativeScrollMock).not.toHaveBeenCalled();
expect(element.scrollLeft).toEqual(100);
});
test('animates left with custom function', async () => {
const element = renderScrollableElement();
element.scrollLeft = 500;
smoothScroll(element, 100);
await usesCustomScrollingFunction(element, 100);
});
});
17 changes: 0 additions & 17 deletions src/tabs/__tests__/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -771,23 +771,6 @@ describe('Tabs', () => {
});

describe('Dismissible', () => {
test('scalls requestAnimationFrame for focus updates', () => {
const time = 0;
const requestAnimationFrameSpy: jest.SpyInstance<number> = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(callback => {
callback(time);
return time;
});

const wrapper = renderTabs(<Tabs tabs={actionDismissibleTabs} activeTabId="fourth" />).wrapper;
wrapper.findActiveTab()!.getElement().focus();
pressRight(wrapper);

expect(requestAnimationFrameSpy).not.toBeCalledTimes(0);
requestAnimationFrameSpy.mockRestore();
});

test('renders the correct dismiss label', () => {
const dismissibleButton = renderTabs(
<Tabs tabs={actionDismissibleTabs} />
Expand Down
7 changes: 0 additions & 7 deletions src/tabs/native-smooth-scroll-supported.ts

This file was deleted.

51 changes: 2 additions & 49 deletions src/tabs/smooth-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,15 @@
// SPDX-License-Identifier: Apache-2.0
import { isMotionDisabled } from '@cloudscape-design/component-toolkit/internal';

import isNativeSmoothScrollingSupported from './native-smooth-scroll-supported';

interface ScrollContext {
scrollable: HTMLElement;
startX: number;
endX: number;
startTime: number;
scrollTime: number;
}

// The scroll speed depends on the scrolling distance. The equation below is an interpolation of measurements in Chrome.
const getScrollSpeed = (pixels: number) => 0.0015 * Math.abs(pixels) + 0.558;
const getScrollTime = (pixels: number) => Math.round(Math.abs(pixels) / getScrollSpeed(pixels));

const now = () => (window.performance ? window.performance.now() : Date.now());

const ease = (k: number): number => {
return 0.5 * (1 - Math.cos(Math.PI * k));
};

const step = (context: ScrollContext): void => {
const time = now();
const elapsed = Math.min((time - context.startTime) / context.scrollTime, 1);
const value = ease(elapsed);
const currentX = context.startX + (context.endX - context.startX) * value;
context.scrollable.scrollLeft = currentX;
// scroll more if we have not reached our destination
if (currentX !== context.endX) {
requestAnimationFrame(() => step(context));
}
};

const simulateSmoothScroll = (element: HTMLElement, endX: number): void => {
const startX = element.scrollLeft;
step({
scrollable: element,
startX,
endX,
startTime: now(),
scrollTime: getScrollTime(endX - startX),
});
};

const smoothScroll = (element: HTMLElement, to: number) => {
if (isMotionDisabled(element)) {
if (isMotionDisabled(element) || !element.scrollTo) {
element.scrollLeft = to;
return;
}
if (isNativeSmoothScrollingSupported() && element.scrollTo) {
} else {

Check warning on line 8 in src/tabs/smooth-scroll.ts

View check run for this annotation

Codecov / codecov/patch

src/tabs/smooth-scroll.ts#L8

Added line #L8 was not covered by tests
element.scrollTo({
left: to,
behavior: 'smooth',
});
return;
}
simulateSmoothScroll(element, to);
};

export default smoothScroll;
2 changes: 2 additions & 0 deletions src/tabs/tab-header-bar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ $label-horizontal-spacing: awsui.$space-xs;
overflow-y: hidden;
position: relative;
inline-size: 100%;
scroll-snap-type: inline proximity;
// do not use pointer-events none because it disables scroll by sliding

// Hide scrollbar in all browsers
Expand Down Expand Up @@ -74,6 +75,7 @@ $label-horizontal-spacing: awsui.$space-xs;
flex-shrink: 0;
display: flex;
max-inline-size: calc(90% - awsui.$space-l);
scroll-snap-align: start;
}

.tabs-tab-label {
Expand Down
37 changes: 2 additions & 35 deletions src/tabs/tab-header-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,7 @@ import {
GeneratedAnalyticsMetadataTabsSelect,
} from './analytics-metadata/interfaces';
import { TabsProps } from './interfaces';
import {
hasHorizontalOverflow,
hasInlineEndOverflow,
hasInlineStartOverflow,
onPaginationClick,
scrollIntoView,
} from './scroll-utils';
import { hasHorizontalOverflow, hasInlineEndOverflow, hasInlineStartOverflow, onPaginationClick } from './scroll-utils';

import analyticsSelectors from './analytics-metadata/styles.css.js';
import styles from './styles.css.js';
Expand Down Expand Up @@ -128,41 +122,14 @@ export function TabHeaderBar({
}
}, [widthChange, tabs]);

const scrollIntoViewIfPossible = (smooth: boolean) => {
if (!activeTabId) {
return;
}
const activeTabRef = tabRefs.current.get(activeTabId);
if (activeTabRef && headerBarRef.current) {
scrollIntoView(activeTabRef, headerBarRef.current, smooth);
}
};

useEffect(() => {
// Delay scrollIntoView as the position is depending on parent elements
// (effects are called inside-out in the component tree).
// Wait one frame to allow parents to complete it's calculation.
requestAnimationFrame(() => {
scrollIntoViewIfPossible(false);
});
// Non-smooth scrolling should not be called upon activeId change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [horizontalOverflow, widthChange, tabs.length]);

useEffect(() => {
scrollIntoViewIfPossible(true);
// Smooth scrolling should only be called upon activeId change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]);

useEffect(() => {
/*
When the selected tab changes and we are currently already focused on a tab,
move the focus to the newly selected tab.
*/
if (headerBarRef.current?.contains(document.activeElement)) {
if (document.activeElement !== activeTabHeaderRef.current) {
activeTabHeaderRef.current?.focus({ preventScroll: true });
activeTabHeaderRef.current?.focus();
}
}
}, [activeTabId]);
Expand Down
Loading