feat(web): refactor pricing modal scrolling and accessibility (#34011)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-03-24 17:18:36 +08:00 committed by GitHub
parent 0c3d11f920
commit d14635625c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 76 additions and 37 deletions

View File

@ -1,12 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import { Dialog } from '@/app/components/base/ui/dialog' import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import Header from '../header' import Header from '../header'
function renderHeader(onClose: () => void) { function renderHeader(onClose: () => void) {
return render( return render(
<Dialog open> <Dialog open>
<Header onClose={onClose} /> <DialogContent>
<Header onClose={onClose} />
</DialogContent>
</Dialog>, </Dialog>,
) )
} }
@ -24,7 +26,7 @@ describe('Header', () => {
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
}) })
}) })
@ -33,7 +35,7 @@ describe('Header', () => {
const handleClose = vi.fn() const handleClose = vi.fn()
renderHeader(handleClose) renderHeader(handleClose)
fireEvent.click(screen.getByRole('button')) fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(handleClose).toHaveBeenCalledTimes(1) expect(handleClose).toHaveBeenCalledTimes(1)
}) })
@ -41,11 +43,11 @@ describe('Header', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should render structural elements with translation keys', () => { it('should render structural elements with translation keys', () => {
const { container } = renderHeader(vi.fn()) renderHeader(vi.fn())
expect(container.querySelector('span')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(container.querySelector('p')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
}) })
}) })
}) })

View File

@ -68,6 +68,7 @@ describe('Pricing', () => {
it('should render pricing header and localized footer link', () => { it('should render pricing header and localized footer link', () => {
render(<Pricing onCancel={vi.fn()} />) render(<Pricing onCancel={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'billing.plansCommon.title.plans' })).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features') expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
}) })

View File

@ -28,8 +28,9 @@ const Footer = ({
<span className="flex h-fit items-center gap-x-1 text-saas-dify-blue-accessible"> <span className="flex h-fit items-center gap-x-1 text-saas-dify-blue-accessible">
<Link <Link
href={pricingPageURL} href={pricingPageURL}
className="system-md-regular" className="system-md-regular hover:underline focus-visible:underline focus-visible:outline-none"
target="_blank" target="_blank"
rel="noopener noreferrer"
> >
{t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })} {t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })}
</Link> </Link>

View File

@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DialogDescription, DialogTitle } from '@/app/components/base/ui/dialog'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Button from '../../base/button' import Button from '../../base/button'
import DifyLogo from '../../base/logo/dify-logo' import DifyLogo from '../../base/logo/dify-logo'
@ -18,24 +19,25 @@ const Header = ({
<div className="flex min-h-[105px] w-full justify-center px-10"> <div className="flex min-h-[105px] w-full justify-center px-10">
<div className="relative flex max-w-[1680px] grow flex-col justify-end gap-y-1 border-x border-divider-accent p-6 pt-8"> <div className="relative flex max-w-[1680px] grow flex-col justify-end gap-y-1 border-x border-divider-accent p-6 pt-8">
<div className="flex items-end"> <div className="flex items-end">
<div className="py-[5px]"> <div aria-hidden="true" className="py-[5px]">
<DifyLogo className="h-[27px] w-[60px]" /> <DifyLogo className="h-[27px] w-[60px]" />
</div> </div>
<span <DialogTitle
className={cn( className={cn(
'bg-billing-plan-title-bg bg-clip-text px-1.5 text-[37px] leading-[1.2] text-transparent', 'bg-billing-plan-title-bg bg-clip-text px-1.5 text-[37px] leading-[1.2] text-transparent',
styles.instrumentSerif, styles.instrumentSerif,
)} )}
> >
{t('plansCommon.title.plans', { ns: 'billing' })} {t('plansCommon.title.plans', { ns: 'billing' })}
</span> </DialogTitle>
</div> </div>
<p className="text-text-tertiary system-sm-regular"> <DialogDescription className="text-text-tertiary system-sm-regular">
{t('plansCommon.title.description', { ns: 'billing' })} {t('plansCommon.title.description', { ns: 'billing' })}
</p> </DialogDescription>
<Button <Button
variant="secondary" variant="secondary"
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2" className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
aria-label={t('operation.close', { ns: 'common' })}
onClick={onClose} onClick={onClose}
> >
<span aria-hidden="true" className="i-ri-close-line size-5" /> <span aria-hidden="true" className="i-ri-close-line size-5" />

View File

@ -4,6 +4,14 @@ import type { Category } from './types'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import {
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/app/components/base/ui/scroll-area'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n' import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
@ -19,6 +27,15 @@ type PricingProps = {
onCancel: () => void onCancel: () => void
} }
const pricingScrollAreaClassNames = {
root: 'relative h-full w-full overflow-hidden [--scroll-area-edge-hint-bg:var(--color-saas-background)]',
viewport: 'overscroll-contain',
content: 'min-h-full min-w-[1200px]',
verticalScrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:me-1',
horizontalScrollbar: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-0.5',
corner: 'bg-saas-background',
} as const
const Pricing: FC<PricingProps> = ({ const Pricing: FC<PricingProps> = ({
onCancel, onCancel,
}) => { }) => {
@ -42,30 +59,46 @@ const Pricing: FC<PricingProps> = ({
}} }}
> >
<DialogContent <DialogContent
className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-auto rounded-none border-none bg-saas-background p-0 shadow-none" className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none"
> >
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden"> <ScrollAreaRoot className={pricingScrollAreaClassNames.root}>
<div className="absolute -top-12 left-0 right-0 -z-10"> <ScrollAreaViewport className={pricingScrollAreaClassNames.viewport}>
<NoiseTop /> <ScrollAreaContent className={pricingScrollAreaClassNames.content}>
</div> <div className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<Header onClose={onCancel} /> <div className="absolute -top-12 left-0 right-0 -z-10">
<PlanSwitcher <NoiseTop />
currentCategory={currentCategory} </div>
onChangeCategory={setCurrentCategory} <Header onClose={onCancel} />
currentPlanRange={planRange} <PlanSwitcher
onChangePlanRange={setPlanRange} currentCategory={currentCategory}
/> onChangeCategory={setCurrentCategory}
<Plans currentPlanRange={planRange}
plan={plan} onChangePlanRange={setPlanRange}
currentPlan={currentCategory} />
planRange={planRange} <Plans
canPay={canPay} plan={plan}
/> currentPlan={currentCategory}
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} /> planRange={planRange}
<div className="absolute -bottom-12 left-0 right-0 -z-10"> canPay={canPay}
<NoiseBottom /> />
</div> <Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
</div> <div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={pricingScrollAreaClassNames.verticalScrollbar}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar
orientation="horizontal"
className={pricingScrollAreaClassNames.horizontalScrollbar}
>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaCorner className={pricingScrollAreaClassNames.corner} />
</ScrollAreaRoot>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )