Skip to content

Commit

Permalink
fix(tabs): scroll exceeding tabs limit (#4722)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Rúben Carvalho <rubcar@sapo.pt>
  • Loading branch information
mizgaionutalexandru and rubencarvalho authored Oct 10, 2024
1 parent 43cf086 commit fc9a448
Show file tree
Hide file tree
Showing 3 changed files with 231 additions and 42 deletions.
25 changes: 24 additions & 1 deletion packages/tabs/src/Tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,35 @@ export class Tabs extends SizedMixin(Focusable, { noDefaultSize: true }) {
return this.rovingTabindexController.focusInElement || this;
}

private limitDeltaToInterval(min: number, max: number) {
return (delta: number): number => {
if (delta < min) return min;
if (delta > max) return max;
return delta;
};
}

/**
* Scrolls through the tabs component, on the X-axis, by a given ammount of pixels/ delta. The given delta is limited to the scrollable area of the tabs component.
* @param {number} delta - The ammount of pixels to scroll by. If the value is positive, the tabs will scroll to the right. If the value is negative, the tabs will scroll to the left.
* @param {ScrollBehavior} behavior - The scroll behavior to use. Defaults to 'smooth'.
*/
public scrollTabs(
delta: number,
behavior: ScrollBehavior = 'smooth'
): void {
if (delta === 0) return;

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

const limitDelta =
this.dir === 'ltr'
? this.limitDeltaToInterval(-scrollLeft, dirLimit)
: this.limitDeltaToInterval(-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;
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
245 changes: 205 additions & 40 deletions packages/tabs/test/tabs-overflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,78 +9,89 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import '@spectrum-web-components/theme/sp-theme.js';
import '@spectrum-web-components/theme/scale-medium.js';
import '@spectrum-web-components/theme/theme-light.js';
import '@spectrum-web-components/tabs/sp-tab.js';
import '@spectrum-web-components/tabs/sp-tabs.js';
import '@spectrum-web-components/tabs/sp-tab-panel.js';
import '@spectrum-web-components/tabs/sp-tabs-overflow.js';
import { ActionButton } from '@spectrum-web-components/action-button';
import { isFirefox } from '@spectrum-web-components/shared/src/platform.js';
import {
calculateScrollTargetForLeftSide,
calculateScrollTargetForRightSide,
Tab,
Tabs,
TabsOverflow,
} from '@spectrum-web-components/tabs';
import { ActionButton } from '@spectrum-web-components/action-button';
import '@spectrum-web-components/tabs/sp-tab-panel.js';
import '@spectrum-web-components/tabs/sp-tab.js';
import '@spectrum-web-components/tabs/sp-tabs-overflow.js';
import '@spectrum-web-components/tabs/sp-tabs.js';
import '@spectrum-web-components/theme/scale-medium.js';
import '@spectrum-web-components/theme/sp-theme.js';
import '@spectrum-web-components/theme/theme-light.js';

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 { sendKeys, setViewport } from '@web/test-runner-commands';
import { repeat } from 'lit/directives/repeat.js';

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

type OverflowProperties = {
count: number;
size: ElementSize;
includeTabPanel: boolean;
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 +180,80 @@ describe('TabsOverflow', () => {
expect(finalLeft).to.be.lessThanOrEqual(initialLeft);
});

it('should scroll up to the last item and back in LTR', async () => {
// TODO: run on iPhone as per https://github.com/adobe/spectrum-web-components/pull/4722
// await setUserAgent(
// 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
// );

// Skipping on Firefox due to timeouts on CI
if (isFirefox()) return;

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;
});

it('should scroll up to the last item and back in RTL', async () => {
// TODO: run on iPhone as per https://github.com/adobe/spectrum-web-components/pull/4722
// await setUserAgent(
// 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
// );

// Skipping on Firefox due to timeouts on CI
if (isFirefox()) return;

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;

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 +447,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(
() =>
Math.ceil(Math.abs(elementToScroll.scrollLeft)) -
Math.abs(distanceToReach) ===
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()));
}

0 comments on commit fc9a448

Please sign in to comment.