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

fix(tabs): scroll exceeding tabs limit #4722

Merged
merged 11 commits into from
Oct 10, 2024
16 changes: 15 additions & 1 deletion packages/tabs/src/Tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,26 @@ export class Tabs extends SizedMixin(Focusable, { noDefaultSize: true }) {
return this.rovingTabindexController.focusInElement || this;
}

private limitDelta(min: number, max: number) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a documentation block on these methods!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing that out. Indeed this method especially seemed somewhat ambiguous. I changed it's implementation so that it does the same thing but in a cleaner manner. What are your thoughts on this?

return (delta: number): number => Math.min(Math.max(delta, min), max);
}

public scrollTabs(
mizgaionutalexandru marked this conversation as resolved.
Show resolved Hide resolved
delta: number,
behavior: ScrollBehavior = 'smooth'
): void {
if (delta === 0) return;

const { scrollLeft, clientWidth, scrollWidth } = this.tabList;
const dirLimit = scrollWidth - clientWidth - Math.abs(scrollLeft);

rubencarvalho marked this conversation as resolved.
Show resolved Hide resolved
const limitDelta =
this.dir === 'ltr'
? this.limitDelta(-scrollLeft, dirLimit)
: this.limitDelta(-dirLimit, Math.abs(scrollLeft));

this.tabList?.scrollBy({
left: delta,
left: limitDelta(delta),
top: 0,
behavior,
});
Expand Down
3 changes: 2 additions & 1 deletion packages/tabs/src/TabsOverflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,12 @@ export class TabsOverflow extends SizedMixin(SpectrumElement) {
}
}

private scrollFactor = 0.5;
mizgaionutalexandru marked this conversation as resolved.
Show resolved Hide resolved
private _handleScrollClick(event: MouseEvent): void {
const currentTarget = event.currentTarget as HTMLElement;
const [tabsElement] = this.scrollContent;

const dist = tabsElement.clientWidth * 0.5;
const dist = tabsElement.clientWidth * this.scrollFactor;
const left = currentTarget.classList.contains('left-scroll')
? -dist
: dist;
Expand Down
222 changes: 190 additions & 32 deletions packages/tabs/test/tabs-overflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@ import {
} from '@spectrum-web-components/tabs';
import { ActionButton } from '@spectrum-web-components/action-button';

import { elementUpdated, expect, fixture } from '@open-wc/testing';
import { elementUpdated, expect, fixture, waitUntil } from '@open-wc/testing';
import {
ElementSize,
ElementSizes,
html,
nothing,
} from '@spectrum-web-components/base';
import { repeat } from 'lit/directives/repeat.js';
import { sendKeys, setUserAgent, setViewport } from '@web/test-runner-commands';

const RIGHT_BUTTON_SELECTOR = '.right-scroll';
const LEFT_BUTTON_SELECTOR = '.left-scroll';

type OverflowProperties = {
count: number;
Expand All @@ -41,46 +45,52 @@ type OverflowProperties = {
selected?: number;
labelPrev?: string;
labelNext?: string;
dir?: 'ltr' | 'rtl';
};

const renderTabsOverflow = async ({
count,
size,
includeTabPanel,
selected = 1,
dir = 'ltr',
}: OverflowProperties): Promise<HTMLDivElement> => {
const tabsContainer = await fixture<HTMLDivElement>(html`
<div class="container" style="width: 200px; height: 150px;">
<sp-tabs-overflow>
<sp-tabs size=${size} selected=${selected}>
${repeat(
new Array(count),
(item) => item,
(_item, index) => html`
<sp-tab
label=${`Tab Item ${index + 1}`}
value=${index + 1}
></sp-tab>
`
)}
${includeTabPanel
? html`
${repeat(
new Array(count),
(item) => item,
(_item, index) => html`
<sp-tab-panel value=${index + 1}>
Content for Tab Item ${index + 1}
</sp-tab-panel>
`
)}
`
: nothing}
</sp-tabs>
</sp-tabs-overflow>
</div>
const theme = await fixture<HTMLDivElement>(html`
<sp-theme dir=${dir} system="spectrum" scale="medium" color="light">
<div class="container" style="width: 200px; height: 150px;">
<sp-tabs-overflow>
<sp-tabs size=${size} selected=${selected}>
${repeat(
new Array(count),
(item) => item,
(_item, index) => html`
<sp-tab
label=${`Tab Item ${index + 1}`}
value=${index + 1}
></sp-tab>
`
)}
${includeTabPanel
? html`
${repeat(
new Array(count),
(item) => item,
(_item, index) => html`
<sp-tab-panel value=${index + 1}>
Content for Tab Item ${index + 1}
</sp-tab-panel>
`
)}
`
: nothing}
</sp-tabs>
</sp-tabs-overflow>
</div>
</sp-theme>
`);
await elementUpdated(tabsContainer);
await elementUpdated(theme);
const tabsContainer = theme.querySelector('.container') as HTMLDivElement;

return tabsContainer;
};

Expand Down Expand Up @@ -169,6 +179,74 @@ describe('TabsOverflow', () => {
expect(finalLeft).to.be.lessThanOrEqual(initialLeft);
});

xit('should scroll up to the last item and back in LTR', async () => {
// setUserAgent is not currently supported by Playwright
await setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
);

const el = await renderTabsOverflow({
count: 8,
size: ElementSizes.L,
includeTabPanel: true,
dir: 'ltr',
});
await elementUpdated(el);
await setViewport({ width: 360, height: 640 });
await nextFrame();

const tabsOverflow = el.querySelector(
'sp-tabs-overflow'
) as TabsOverflow;

expect(tabsOverflow['overflowState'].canScrollLeft).to.be.false;
expect(tabsOverflow['overflowState'].canScrollRight).to.be.true;

await scrollToEnd(el, RIGHT_BUTTON_SELECTOR, 'ltr');

expect(tabsOverflow['overflowState'].canScrollLeft).to.be.true;
expect(tabsOverflow['overflowState'].canScrollRight).to.be.false;

await scrollToEnd(el, LEFT_BUTTON_SELECTOR, 'ltr');

expect(tabsOverflow['overflowState'].canScrollLeft).to.be.false;
expect(tabsOverflow['overflowState'].canScrollRight).to.be.true;
});

xit('should scroll up to the last item and back in RTL', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it work and make sense to let these tests run, even without setting the user agent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can run the tests if we remove the setUserAgent part, this is how I created them in the first place. I think it would make sense to run these for desktop as well, to cover the scenario.

The thing with this approach is that they wouldn't fail even if this bug is reproducible on iOS and I think is good to signal in some way that these tests must be enabled once we have a way to run them on iPad/iOS/Android. I'm thinking to enable them in the current tests contexts and commenting the setUserAgent part, along with a comment like this:

// TODO: run on iPhone as per https://github.com/adobe/spectrum-web-components/pull/4722

What do you think, do you have any suggestions on this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that approach is good. While this bug is iOS-only, this test is running in a mobile viewport which still helps with our testing coverage 👍

// setUserAgent is not currently supported by Playwright
await setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
);

const el = await renderTabsOverflow({
count: 8,
size: ElementSizes.L,
includeTabPanel: true,
dir: 'rtl',
});
await elementUpdated(el);
await setViewport({ width: 360, height: 640 });
await nextFrame();

const tabsOverflow = el.querySelector(
'sp-tabs-overflow'
) as TabsOverflow;

expect(tabsOverflow['overflowState'].canScrollLeft).to.be.true;
expect(tabsOverflow['overflowState'].canScrollRight).to.be.false;
mizgaionutalexandru marked this conversation as resolved.
Show resolved Hide resolved

await scrollToEnd(el, LEFT_BUTTON_SELECTOR, 'rtl');

expect(tabsOverflow['overflowState'].canScrollLeft).to.be.false;
expect(tabsOverflow['overflowState'].canScrollRight).to.be.true;

await scrollToEnd(el, RIGHT_BUTTON_SELECTOR, 'rtl');

expect(tabsOverflow['overflowState'].canScrollLeft).to.be.true;
expect(tabsOverflow['overflowState'].canScrollRight).to.be.false;
});

it('should fail properly if slot is not sp-tabs', async () => {
const el = await fixture<TabsOverflow>(html`
<sp-tabs-overflow>
Expand Down Expand Up @@ -362,3 +440,83 @@ describe('calculateScrollTargetForLeftSide', () => {
).to.equal(0);
});
});

async function repeatScroll(
options: {
times: number;
elementToUpdate: TabsOverflow;
elementToScroll: HTMLElement;
distanceToReachInIteration: (iteration: number) => number;
},
iteration = 1
): Promise<void> {
const {
times,
elementToUpdate,
elementToScroll,
distanceToReachInIteration,
} = options;
if (iteration > times) return;

const distanceToReach = distanceToReachInIteration(iteration);

await sendKeys({ press: 'Enter' });
await elementUpdated(elementToUpdate);
await waitUntil(
mizgaionutalexandru marked this conversation as resolved.
Show resolved Hide resolved
() =>
Math.ceil(Math.abs(elementToScroll.scrollLeft)) -
Math.abs(distanceToReach) ===
mizgaionutalexandru marked this conversation as resolved.
Show resolved Hide resolved
0,
`scroll to ${distanceToReach}`
);
return await repeatScroll(options, iteration + 1);
}

async function scrollToEnd(
tabsContainer: HTMLDivElement,
buttonSelector: string,
direction: 'ltr' | 'rtl' = 'ltr'
): Promise<void> {
const tabs = tabsContainer.querySelector('sp-tabs') as Tabs;
const tabsList = tabs.shadowRoot!.querySelector('#list') as HTMLElement;
const tabsOverflow = tabsContainer.querySelector(
'sp-tabs-overflow'
) as TabsOverflow;
const button = tabsOverflow.shadowRoot.querySelector(
buttonSelector
) as ActionButton;

const { scrollWidth, clientWidth } = tabsList;
const distPerScroll = clientWidth * tabsOverflow['scrollFactor'];
const totalScrollDist = scrollWidth - clientWidth;
const scrollsToEnd = Math.ceil(totalScrollDist / distPerScroll);
let distanceToReachInIteration: (iteration: number) => number;

if (direction === 'ltr') {
distanceToReachInIteration =
buttonSelector === LEFT_BUTTON_SELECTOR
? (iteration: number) =>
Math.max(totalScrollDist - iteration * distPerScroll, 0)
: (iteration: number) =>
Math.min(iteration * distPerScroll, totalScrollDist);
} else {
distanceToReachInIteration =
buttonSelector === LEFT_BUTTON_SELECTOR
? (iteration: number) =>
Math.max(-1 * iteration * distPerScroll, -totalScrollDist)
: (iteration: number) =>
-Math.max(totalScrollDist - iteration * distPerScroll, 0);
}

button.focus();
return await repeatScroll({
times: scrollsToEnd,
elementToUpdate: tabsOverflow,
elementToScroll: tabsList,
distanceToReachInIteration,
});
}

function nextFrame(): Promise<void> {
return new Promise((res) => requestAnimationFrame(() => res()));
}
Loading