Skip to content

Commit

Permalink
feat: Introduce scroll snapping to responsive tabs (#3088)
Browse files Browse the repository at this point in the history
  • Loading branch information
timogasda authored and pan-kot committed Dec 4, 2024
1 parent 40eb1f8 commit b28f553
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 125 deletions.
97 changes: 97 additions & 0 deletions pages/autosuggest/search.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useRef, useState } from 'react';

import { Badge, Box, Checkbox, ExpandableSection, Header, SpaceBetween } from '~components';
import Autosuggest, { AutosuggestProps } from '~components/autosuggest';

import AppContext, { AppContextType } from '../app/app-context';

type PageContext = React.Context<
AppContextType<{
empty?: boolean;
showEnteredTextOption?: boolean;
showMatchesCount?: boolean;
}>
>;

const options = [
{ value: '__apple__', label: 'Apple' },
{ value: '__orange__', label: 'Orange', tags: ['sweet'] },
{ value: '__banana__', label: 'Banana', tags: ['sweet'] },
{ value: '__pineapple__', label: 'Pineapple', description: 'pine+apple' },
];
const enteredTextLabel = (value: string) => `Use: ${value}`;

export default function AutosuggestPage() {
const {
urlParams: { empty = false, showEnteredTextOption = true, showMatchesCount = true },
setUrlParams,
} = useContext(AppContext as PageContext);
const [value, setValue] = useState('');
const [selection, setSelection] = useState('');
const ref = useRef<AutosuggestProps.Ref>(null);
return (
<Box margin="m">
<SpaceBetween size="m">
<Header
variant="h1"
description="This demo shows how an updated version of Autosuggest can be used as a search input"
>
Search
</Header>

<ExpandableSection defaultExpanded={true} headerText="Settings">
<Checkbox checked={empty} onChange={({ detail }) => setUrlParams({ empty: detail.checked })}>
Empty
</Checkbox>
<Checkbox
checked={showEnteredTextOption}
onChange={({ detail }) => setUrlParams({ showEnteredTextOption: detail.checked })}
>
Show entered text option
</Checkbox>
<Checkbox
checked={showMatchesCount}
onChange={({ detail }) => setUrlParams({ showMatchesCount: detail.checked })}
>
Show matches count
</Checkbox>
</ExpandableSection>

<Autosuggest
ref={ref}
value={value}
options={empty ? [] : options}
onChange={event => setValue(event.detail.value)}
onSelect={event => {
if (event.detail.selectedOption?.value) {
setSelection(event.detail.selectedOption.value);
setValue('');
}
}}
enteredTextLabel={enteredTextLabel}
ariaLabel={'simple autosuggest'}
selectedAriaLabel="Selected"
empty="No suggestions"
showEnteredTextOption={showEnteredTextOption}
filteringResultsText={
showMatchesCount
? matchesCount => {
matchesCount = showEnteredTextOption ? matchesCount - 1 : matchesCount;
return matchesCount ? `${matchesCount} items` : `No matches`;
}
: undefined
}
/>

<SpaceBetween size="s" direction="horizontal">
<Badge color={selection === '__apple__' ? 'green' : 'grey'}>Apple</Badge>
<Badge color={selection === '__orange__' ? 'green' : 'grey'}>Orange</Badge>
<Badge color={selection === '__banana__' ? 'green' : 'grey'}>Banana</Badge>
<Badge color={selection === '__pineapple__' ? 'green' : 'grey'}>Pineapple</Badge>
</SpaceBetween>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2052,6 +2052,14 @@ This is required to provide a good screen reader experience. For more informatio
"optional": true,
"type": "string",
},
{
"defaultValue": "true",
"description": "Defines whether entered text option is shown as the first option in the dropdown when value is non-empty.
Defaults to \`true\`.",
"name": "showEnteredTextOption",
"optional": true,
"type": "boolean",
},
{
"defaultValue": "'finished'",
"description": "Specifies the current status of loading more options.
Expand Down
10 changes: 9 additions & 1 deletion src/autosuggest/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ export { AutosuggestProps };

const Autosuggest = React.forwardRef(
(
{ filteringType = 'auto', statusType = 'finished', disableBrowserAutocorrect = false, ...props }: AutosuggestProps,
{
filteringType = 'auto',
statusType = 'finished',
disableBrowserAutocorrect = false,
showEnteredTextOption = true,
...props
}: AutosuggestProps,
ref: React.Ref<AutosuggestProps.Ref>
) => {
const baseComponentProps = useBaseComponent('Autosuggest', {
Expand All @@ -26,6 +32,7 @@ const Autosuggest = React.forwardRef(
filteringType,
readOnly: props.readOnly,
virtualScroll: props.virtualScroll,
showEnteredTextOption,
},
});

Expand All @@ -43,6 +50,7 @@ const Autosuggest = React.forwardRef(
filteringType={filteringType}
statusType={statusType}
disableBrowserAutocorrect={disableBrowserAutocorrect}
showEnteredTextOption={showEnteredTextOption}
{...externalProps}
{...baseComponentProps}
ref={ref}
Expand Down
6 changes: 6 additions & 0 deletions src/autosuggest/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export interface AutosuggestProps
*/
enteredTextLabel?: AutosuggestProps.EnteredTextLabel;

/**
* Defines whether entered text option is shown as the first option in the dropdown when value is non-empty.
* Defaults to `true`.
*/
showEnteredTextOption?: boolean;

/**
* Specifies the text to display with the number of matches at the bottom of the dropdown menu while filtering.
*/
Expand Down
9 changes: 6 additions & 3 deletions src/autosuggest/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
ariaLabel,
ariaRequired,
enteredTextLabel,
showEnteredTextOption,
filteringResultsText,
onKeyDown,
virtualScroll,
Expand Down Expand Up @@ -91,7 +92,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
filterText: value,
filteringType,
enteredTextLabel,
hideEnteredTextLabel: false,
hideEnteredTextLabel: !showEnteredTextOption,
onSelectItem: (option: AutosuggestItem) => {
const value = option.value || '';
fireNonCancelableEvent(onChange, { value });
Expand Down Expand Up @@ -196,6 +197,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
});

const shouldRenderDropdownContent = !isEmpty || dropdownStatus.content;
const dropdownExpanded = autosuggestItemsState.items.length > 1 || dropdownStatus.content !== null;

return (
<AutosuggestInput
Expand All @@ -222,9 +224,10 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
expandToViewport={expandToViewport}
ariaControls={listId}
ariaActivedescendant={highlightedOptionId}
dropdownExpanded={autosuggestItemsState.items.length > 1 || dropdownStatus.content !== null}
dropdownExpanded={dropdownExpanded}
dropdownContent={
shouldRenderDropdownContent && (
shouldRenderDropdownContent &&
dropdownExpanded && (
<AutosuggestOptionsList
statusType={statusType}
autosuggestItemsState={autosuggestItemsState}
Expand Down
1 change: 1 addition & 0 deletions src/tabs/__integ__/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ test(
await page.setWindowSize({ width: 500, height: 1000 });
await page.click('#add-tab');
await page.click(page.paginationButton('right', true));
await page.click(page.paginationButton('right', true));
await page.click(wrapper.findTabLinkByIndex(7).toSelector());
await page.waitForAssertion(async () =>
expect(await page.isExisting(page.paginationButton('right', true))).toBe(false)
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);
});
});
7 changes: 0 additions & 7 deletions src/tabs/native-smooth-scroll-supported.ts

This file was deleted.

22 changes: 0 additions & 22 deletions src/tabs/scroll-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,3 @@ export const hasInlineStartOverflow = (headerBar: HTMLElement): boolean => {
export const hasInlineEndOverflow = (headerBar: HTMLElement): boolean => {
return Math.ceil(getScrollInlineStart(headerBar)) < headerBar.scrollWidth - headerBar.offsetWidth;
};

export const scrollIntoView = (tabHeader: HTMLElement, headerBar: HTMLElement, smooth = true): void => {
if (!tabHeader || !headerBar) {
return;
}
// Extra left and right margin to always make the focus ring visible
const margin = 2;
let updatedLeftScroll = headerBar.scrollLeft;

// Anchor tab to left of scroll parent
updatedLeftScroll = Math.min(updatedLeftScroll, tabHeader.offsetLeft - margin);
// Anchor tab to right of scroll parent
updatedLeftScroll = Math.max(
updatedLeftScroll,
tabHeader.offsetLeft + tabHeader.offsetWidth - headerBar.offsetWidth + margin
);
if (smooth) {
smoothScroll(headerBar, updatedLeftScroll);
} else {
headerBar.scrollLeft = updatedLeftScroll;
}
};
58 changes: 6 additions & 52 deletions src/tabs/smooth-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,16 @@
// 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) {
element.scrollTo({
left: to,
behavior: 'smooth',
});
return;
}
simulateSmoothScroll(element, to);
// istanbul ignore next: unit tests always have motion disabled
element.scrollTo({
left: to,
behavior: 'smooth',
});
};

export default smoothScroll;
4 changes: 4 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 Expand Up @@ -205,6 +207,7 @@ $label-horizontal-spacing: awsui.$space-xs;
// Remediate focus shadow
.tabs-tab:first-child {
margin-inline-start: 1px;
scroll-margin-inline-start: 1px;
& > .tabs-tab-header-container {
padding-inline-start: calc(#{$label-horizontal-spacing} - 1px);
}
Expand All @@ -213,6 +216,7 @@ $label-horizontal-spacing: awsui.$space-xs;
// Remediate focus shadow
.tabs-tab:last-child {
margin-inline-end: 1px;
scroll-margin-inline-end: 1px;
& > .tabs-tab-header-container {
padding-inline-end: calc(#{$label-horizontal-spacing} - 1px);
}
Expand Down
Loading

0 comments on commit b28f553

Please sign in to comment.