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
74 changes: 74 additions & 0 deletions test/e2e/page-objects/pages/asset/asset-sticky-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Driver } from '../../../webdriver/driver';

/**
* Sticky Buy / Swap CTA bar on the Token Detail Page V2.
*
* Screen: `#/asset/:chainId/:asset?/:id?`
* Owns: presence of the sticky footer and its Buy / Swap buttons, plus
* asserting the bar stays pinned to the bottom of the viewport after scroll.
* Boundaries: does not own destinations of Buy / Swap clicks.
*
* @see ui/pages/asset/components/asset-sticky-actions.tsx
*/
class AssetStickyActions {
private driver: Driver;

private readonly scrollContainer =
'[data-testid="asset-page-scroll-container"]';

private readonly stickyActions = '[data-testid="asset-sticky-actions"]';

private readonly stickyBuy = '[data-testid="asset-sticky-buy"]';

private readonly stickySwap = '[data-testid="asset-sticky-swap"]';

constructor(driver: Driver) {
this.driver = driver;
}

/**
* Waits for the sticky Buy and Swap CTAs to be present.
*/
async checkPageIsLoaded(): Promise<void> {
console.log('Check asset sticky actions are loaded');
await this.driver.waitForSelector(this.stickyActions);
await this.driver.waitForSelector(this.stickyBuy);
await this.driver.waitForSelector(this.stickySwap);
}

/**
* Asserts the sticky bar is pinned near the bottom of the visual viewport.
* Uses a small tolerance so scrollbar / safe-area padding do not flake.
*/
async checkPinnedToViewportBottom(): Promise<void> {
console.log('Check asset sticky actions are pinned to the viewport bottom');
await this.driver.wait(async () => {
const isPinned = await this.driver.executeScript(`
const bar = document.querySelector('[data-testid="asset-sticky-actions"]');
if (!bar) {
return false;
}
const rect = bar.getBoundingClientRect();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pinning check compares the bar's rect.bottom to window.innerHeight with an 8px tolerance so scrollbar / safe-area padding don't cause flakes.

return Math.abs(rect.bottom - window.innerHeight) < 8 && rect.top >= 0;
`);
return Boolean(isPinned);
}, this.driver.timeout);
}

/**
* Scrolls the Token Detail Page scrollport to the bottom, far enough that a
* non-sticky footer would leave the viewport.
*/
async scrollToBottom(): Promise<void> {
console.log('Scroll the token detail page to the bottom');
await this.driver.waitForSelector(this.scrollContainer);
await this.driver.executeScript(`
const scroller = document.querySelector('[data-testid="asset-page-scroll-container"]');
if (scroller) {
scroller.scrollTo(0, scroller.scrollHeight);
}
`);
}
}

export default AssetStickyActions;
63 changes: 63 additions & 0 deletions test/e2e/tests/tokens/asset-sticky-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Mockttp } from 'mockttp';
import { Context } from 'mocha';
import { CHAIN_IDS } from '../../../../shared/constants/network';
import FixtureBuilderV2 from '../../fixtures/fixture-builder-v2';
import { NETWORK_CLIENT_ID } from '../../constants';
import { withFixtures } from '../../helpers';
import { Driver } from '../../webdriver/driver';
import HomePage from '../../page-objects/pages/home/homepage';
import TokensTab from '../../page-objects/pages/home/tokens-tab';
import AssetStickyActions from '../../page-objects/pages/asset/asset-sticky-actions';
import { login } from '../../page-objects/flows/login.flow';
import { mockHistoricalPricesV3, mockSpotPrices } from './utils/mocks';

describe('Asset sticky actions', function () {
const chainId = CHAIN_IDS.MAINNET;

it('keeps Buy and Swap pinned to the bottom while the token detail page scrolls', async function () {
await withFixtures(
{
fixtures: new FixtureBuilderV2()
.withSelectedNetwork(NETWORK_CLIENT_ID.MAINNET)
.withEnabledNetworks({ eip155: { [chainId]: true } })
.build(),
title: (this as Context).test?.fullTitle(),
ethConversionInUsd: 1700,
// Known SubscriptionsController startup race, unrelated to this page.
// Tracked in https://github.com/MetaMask/metamask-extension/issues/45612
ignoredConsoleErrors: ['getSubscriptions'],

@salimtb salimtb Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Filtering the getSubscriptions console error , it's a known SubscriptionsController startup race unrelated to this page, and acceptable to ignore in an e2e assertion focused on footer positioning.

localNodeOptions: {
chainId: parseInt(chainId, 16),
},
testSpecificMock: async (mockServer: Mockttp) => [
await mockSpotPrices(mockServer, {
'eip155:1/slip44:60': {
price: 1700,
marketCap: 382623505141,
pricePercentChange1d: 0,
},
}),
await mockHistoricalPricesV3(mockServer, 'eip155:1', 'slip44:60'),
],
},
async ({ driver }: { driver: Driver }) => {
await login(driver);

const homePage = new HomePage(driver);
await homePage.checkPageIsLoaded();

const tokensTab = new TokensTab(driver);
await tokensTab.openTokenDetails('Ethereum');

const stickyActions = new AssetStickyActions(driver);
await stickyActions.checkPageIsLoaded();
await stickyActions.checkPinnedToViewportBottom();

await stickyActions.scrollToBottom();

await stickyActions.checkPageIsLoaded();
await stickyActions.checkPinnedToViewportBottom();
},
);
});
});
4 changes: 3 additions & 1 deletion ui/components/app/wallet-overview/coin-buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ const NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN: { [key: string]: BridgeAsset } = {
[ARC_HEX_CHAIN_ID]: ARC_ERC20_USDC_BRIDGE_ASSET,
};

function getSwapNativeTokenWithOverridesForChain(chainId: string): BridgeAsset {
export function getSwapNativeTokenWithOverridesForChain(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Only change here: export this helper so the new sticky bar can reuse the exact native-swap override logic instead of duplicating it. No behavior change.

chainId: string,
): BridgeAsset {
const override = NATIVE_SWAP_TOKEN_OVERRIDE_PER_CHAIN[chainId];
return override ?? getNativeAssetForChainId(chainId);
}
Expand Down
2 changes: 1 addition & 1 deletion ui/components/multichain/toast/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
}

/* Lift the toaster above fixed CTA footers */
:root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar) {
:root:has(.cta-footer, .multichain-page-footer, .dapp-connection-control-bar, .bottom-nav-bar, .asset-page__sticky-actions) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adds the new sticky bar to the list of fixed footers the toaster lifts above, so toasts don't render underneath it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@salimtb can we use .cta-footer? It's primary used for toast avoidance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sure , let me do it now

--toaster-bottom-offset: 80px;
}

Expand Down
32 changes: 32 additions & 0 deletions ui/pages/asset/asset.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@
.asset {
&__container {
background-color: var(--color-background-default);
// `.main-container` owns `overflow-y: auto`, but the app shell's flex chain
// lets it keep its full content height, so it never actually scrolls — the
// scrolling happens further up on `.app`. That silently breaks
// `position: sticky` for the bottom CTA bar, whose nearest scrollport is
// then this non-scrolling box. Letting it shrink makes it the real
// scrollport so the CTA bar can pin to the bottom of the viewport.
min-height: 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The min-height: 0 here (and on the wrapper) is the load-bearing fix: without it the container never actually scrolls, so position: sticky on the CTA bar has no scrollport to stick against. Comment above explains the flex chain.

}
}

.main-container-wrapper:has(> .asset__container) {
min-height: 0;
}

.asset-navigation {
display: flex;
align-items: center;
Expand Down Expand Up @@ -107,3 +118,24 @@
height: 100%;
border-radius: 1rem;
}

// Persistent bottom CTA bar (Buy / Swap) for the Token Detail Page V2. Mirrors
// the Mobile sticky footer: pinned to the bottom of the scroll area, elevated
// above the content, and padded for the device safe-area inset.
.asset-page__sticky-actions {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you try moving these to tailwind? Trying to avoid adding more Sass

cc @georgewrmarshall

position: sticky;
inset-inline: 0;
bottom: 0;
// Direct flex child of the scrolling `.main-container`: never let it be
// compressed, and keep it at the bottom even when the page is short enough
// that it does not scroll.
flex-shrink: 0;
margin-top: auto;
z-index: 1;
background-color: var(--color-background-default);
border-top: 1px solid var(--color-border-muted);
box-shadow: 0 -4px 12px var(--color-shadow-default);
padding: 12px 16px;
// Safe-area handling for devices with a home indicator / rounded corners.
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mirrors the mobile sticky footer: pinned bottom, elevated above content, and env(safe-area-inset-bottom) padding for home-indicator / rounded-corner devices.

}
5 changes: 4 additions & 1 deletion ui/pages/asset/asset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ const Asset = () => {
]);

return (
<ScrollContainer className="main-container asset__container">
<ScrollContainer
className="main-container asset__container"
data-testid="asset-page-scroll-container"
>
{renderContent()}
</ScrollContainer>
);
Expand Down
9 changes: 9 additions & 0 deletions ui/pages/asset/components/asset-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import { isMusdToken } from '../../../components/app/musd/constants';
import { processAssetParams } from '../util';
import { AssetInactiveBadge } from '../../../components/app/assets/asset-inactive-badge/asset-inactive-badge';
import { AssetMarketDetails } from './asset-market-details';
import { AssetStickyActions } from './asset-sticky-actions';
import AssetChart from './chart/asset-chart';
import { MarketClosedActionButton } from './market-closed-action-button';
import TokenButtons from './token-buttons';
Expand Down Expand Up @@ -732,6 +733,14 @@ const AssetPage = ({
onClose={() => setIsMarketClosedModalOpen(false)}
/>
</Box>
{/* Sibling of `asset__content` so it is a direct child of the scrolling
container, which is what lets it stick to the bottom of the viewport. */}
<AssetStickyActions

@salimtb salimtb Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mounted as a sibling of asset__content (a direct child of the scrolling container) , that positioning is what lets position: sticky pin it to the bottom of the viewport.

asset={updatedAsset}
buyAssetId={caipAssetId as CaipAssetType}
isMarketClosed={isMarketClosed}
isSigningEnabled={isSigningEnabled}
/>
</AssetPageSecurityTrustProvider>
);
};
Expand Down
105 changes: 105 additions & 0 deletions ui/pages/asset/components/asset-sticky-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import { fireEvent, waitFor } from '@testing-library/react';
import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate';
import { CHAIN_IDS } from '../../../../shared/constants/network';
import { mockNetworkState } from '../../../../test/stub/networks';
import { AssetType } from '../../../../shared/constants/transaction';
import { toAssetId } from '../../../../shared/lib/asset-utils';
import { MetaMetricsSwapsEventSource } from '../../../../shared/constants/metametrics';
import { Asset } from '../types/asset';
import { AssetStickyActions } from './asset-sticky-actions';

const mockGoToBuy = jest.fn().mockResolvedValue(true);
jest.mock('../../../hooks/ramps/useRampsNavigation/useRampsNavigation', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
default: () => ({
goToBuy: mockGoToBuy,
opensBuyInPortfolioTab: false,
}),
}));

const mockOpenBridgeExperience = jest.fn();
jest.mock('../../../hooks/bridge/useBridging', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
default: () => ({ openBridgeExperience: mockOpenBridgeExperience }),
}));

const mockTrackEvent = jest.fn();
jest.mock('../../../hooks/useAnalytics', () => {
const { createEventBuilder } = jest.requireActual(
'../../../../shared/lib/analytics/create-event-builder',
);
return {
useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder }),
};
});

const token = {
type: AssetType.token,
address: '0x6b175474e89094c44da98b954eedeac495271d0f',
chainId: CHAIN_IDS.MAINNET,
decimals: 18,
symbol: 'DAI',
image: '',
} as Asset & { type: AssetType.token };

const store = configureMockStore()({
metamask: {
...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }),
useExternalServices: true,
},
});

describe('AssetStickyActions', () => {
beforeEach(() => jest.clearAllMocks());

it('routes the Buy button through goToBuy with the token as intent assetId', () => {
const { getByTestId } = renderWithProvider(
<AssetStickyActions asset={token} />,
store,
);

fireEvent.click(getByTestId('asset-sticky-buy'));
expect(mockGoToBuy).toHaveBeenCalledWith({
assetId: toAssetId(token.address, token.chainId),
chainId: token.chainId,
});
});

it('does not track a buy click when the ramps gate blocks the buy', async () => {
mockGoToBuy.mockResolvedValueOnce(false);
const { getByTestId } = renderWithProvider(
<AssetStickyActions asset={token} />,
store,
);

fireEvent.click(getByTestId('asset-sticky-buy'));
await waitFor(() => expect(mockGoToBuy).toHaveBeenCalled());
expect(mockTrackEvent).not.toHaveBeenCalled();
});

it('opens the swap experience with the token as the source asset', () => {
const { getByTestId } = renderWithProvider(
<AssetStickyActions asset={token} />,
store,
);

fireEvent.click(getByTestId('asset-sticky-swap'));
expect(mockOpenBridgeExperience).toHaveBeenCalledWith(
MetaMetricsSwapsEventSource.TokenView,
token,
);
});

it('disables swap while the stock market is closed', () => {
const { getByTestId } = renderWithProvider(
<AssetStickyActions asset={token} isMarketClosed />,
store,
);

expect(getByTestId('asset-sticky-swap')).toBeDisabled();
});
});
Loading
Loading