fix(ui): keep loading buttons focusable (#37383)

This commit is contained in:
yyh 2026-06-12 18:31:33 +08:00 committed by GitHub
parent e5d5931fec
commit ad96501e09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 130 additions and 100 deletions

View File

@ -61,6 +61,12 @@ Utilities:
- `./cn``clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root.
## Button loading and disabled contract
`Button` keeps normal `disabled` controls native-disabled by default so unavailable actions are removed from the keyboard focus order.
When `loading` is true, `Button` defaults `focusableWhenDisabled` to true. Loading represents an action that has already been triggered and is temporarily pending, so the button remains focusable while Base UI still suppresses click, pointer, keyboard activation, and submit-button activation. Pass `focusableWhenDisabled={false}` only when a loading button should use native disabled behavior.
## Segmented control contract
`SegmentedControl` is Dify's design-system primitive for mode, filter, and view selection. It is built on Base UI `ToggleGroup` + `Toggle`, so use `Tabs` instead when the UI needs `tablist` / `tabpanel` semantics.

View File

@ -1,4 +1,6 @@
import type { FormEvent } from 'react'
import { render } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import { Button } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
@ -106,9 +108,16 @@ describe('Button', () => {
expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
})
it('auto-disables when loading', async () => {
it('keeps loading buttons focusable by default', async () => {
const screen = await render(<Button loading>Click me</Button>)
await expect.element(screen.getByRole('button')).toBeDisabled()
const button = screen.getByRole('button').element()
expect(button).not.toHaveAttribute('disabled')
expect((button as HTMLButtonElement).disabled).toBe(false)
expect(button).toHaveAttribute('aria-disabled', 'true')
asHTMLElement(button).focus()
expect(button).toHaveFocus()
})
it('sets aria-busy when loading', async () => {
@ -128,10 +137,17 @@ describe('Button', () => {
await expect.element(screen.getByRole('button')).toBeDisabled()
})
it('keeps focusable when loading with focusableWhenDisabled', async () => {
const screen = await render(<Button loading focusableWhenDisabled>Loading</Button>)
it('does not keep normal disabled buttons focusable by default', async () => {
const screen = await render(<Button disabled>Click me</Button>)
const button = screen.getByRole('button').element()
expect(button).toHaveAttribute('aria-disabled', 'true')
expect(button).toBeDisabled()
expect(button).not.toHaveAttribute('aria-disabled')
})
it('allows loading focusability to be opted out', async () => {
const screen = await render(<Button loading focusableWhenDisabled={false}>Loading</Button>)
await expect.element(screen.getByRole('button')).toBeDisabled()
})
})
@ -156,6 +172,35 @@ describe('Button', () => {
asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
it('does not submit a form when a loading submit button is clicked', async () => {
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => event.preventDefault())
const screen = await render(
<form onSubmit={onSubmit}>
<Button type="submit" loading>Submit</Button>
</form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Submit' }).element()).click()
expect(onSubmit).not.toHaveBeenCalled()
})
it('does not implicitly submit a form through a loading submit button', async () => {
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => event.preventDefault())
const screen = await render(
<form onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" />
<Button type="submit" loading>Submit</Button>
</form>,
)
asHTMLElement(screen.getByRole('textbox', { name: 'Name' }).element()).focus()
await userEvent.keyboard('{Enter}')
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('className merging', () => {

View File

@ -11,6 +11,7 @@ const meta = {
tags: ['autodocs'],
argTypes: {
loading: { control: 'boolean' },
focusableWhenDisabled: { control: 'boolean' },
tone: {
control: 'select',
options: ['default', 'destructive'],
@ -90,6 +91,13 @@ export const Loading: Story = {
loading: true,
children: 'Loading Button',
},
parameters: {
docs: {
description: {
story: 'Loading buttons remain focusable by default so focus is not lost after activation. Pass `focusableWhenDisabled={false}` to opt out.',
},
},
},
}
export const Destructive: Story = {
@ -105,7 +113,7 @@ export const WithIcon: Story = {
variant: 'primary',
children: (
<>
<span className="mr-1.5 i-heroicons-rocket-launch-20-solid h-4 w-4" />
<span aria-hidden className="mr-1.5 i-ri-rocket-line size-4 shrink-0" />
Launch
</>
),

View File

@ -112,6 +112,7 @@ export function Button({
tone,
loading,
disabled,
focusableWhenDisabled,
type = 'button',
children,
...props
@ -121,6 +122,7 @@ export function Button({
type={type}
className={cn(buttonVariants({ variant, size, tone, className }))}
disabled={disabled || loading}
focusableWhenDisabled={focusableWhenDisabled ?? loading}
aria-busy={loading || undefined}
{...props}
>

View File

@ -2,6 +2,7 @@ import type { App, AppSSO } from '@/types/app'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { expectLoadingButton } from '@/test/button'
import { AppModeEnum } from '@/types/app'
import AppInfoModals from '../app-info-modals'
@ -265,7 +266,7 @@ describe('AppInfoModals', () => {
const firstClick = user.click(confirmButton)
await waitFor(() => {
expect(confirmButton).toBeDisabled()
expectLoadingButton(confirmButton)
expect(confirmButton).toHaveTextContent('common.operation.exporting')
})
await user.click(confirmButton)

View File

@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'
import * as React from 'react'
import * as ReactI18next from 'react-i18next'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { expectLoadingButton } from '@/test/button'
import { useChatWithHistoryContext } from '../../context'
import Sidebar from '../index'
import RenameModal from '../rename-modal'
@ -550,7 +551,7 @@ describe('Sidebar Index', () => {
render(<Sidebar />)
await user.click(screen.getByTestId('rename-1'))
const saveButton = screen.getByText('common.operation.save').closest('button')
expect(saveButton).toBeDisabled()
expectLoadingButton(saveButton)
})
it('should handle rename for different items', async () => {

View File

@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as ReactI18next from 'react-i18next'
import { expectLoadingButton } from '@/test/button'
import RenameModal from '../rename-modal'
vi.mock('@langgenius/dify-ui/dialog', () => ({
@ -72,8 +73,7 @@ describe('RenameModal', () => {
it('shows loading state when saveLoading is true', () => {
render(<RenameModal {...defaultProps} saveLoading />)
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
expect(saveButton).toBeDisabled()
expect(saveButton).toHaveAttribute('aria-busy', 'true')
expectLoadingButton(saveButton)
expect(saveButton.querySelector('.animate-spin')).toBeInTheDocument()
})

View File

@ -13,6 +13,7 @@ import type { RetrievalConfig } from '@/types/app'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { expectLoadingButton } from '@/test/button'
import { RETRIEVE_METHOD } from '@/types/app'
import { PreviewPanel } from '../components/preview-panel'
import { StepTwoFooter } from '../components/step-two-footer'
@ -1773,14 +1774,14 @@ describe('StepTwoFooter', () => {
render(<StepTwoFooter {...defaultProps} isCreating={true} />)
const nextButton = screen.getByText(/nextStep/i).closest('button')
expect(nextButton)!.toBeDisabled()
expectLoadingButton(nextButton)
})
it('should show loading state on Save button when creating in setting mode', () => {
render(<StepTwoFooter {...defaultProps} isSetting={true} isCreating={true} />)
const saveButton = screen.getByText(/save/i).closest('button')
expect(saveButton)!.toBeDisabled()
expectLoadingButton(saveButton)
})
})
})

View File

@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
// Component Imports (after mocks)
@ -51,8 +52,7 @@ describe('UrlInput', () => {
it('should show loading state on button when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveAttribute('aria-busy', 'true')
expectLoadingButton(button)
expect(button.querySelector('.animate-spin')).toBeInTheDocument()
})

View File

@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
// Component Imports (after mocks)
@ -49,8 +50,7 @@ describe('UrlInput (jina-reader)', () => {
it('should show loading state on button when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveAttribute('aria-busy', 'true')
expectLoadingButton(button)
expect(button.querySelector('.animate-spin')).toBeInTheDocument()
})

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import { CrawlStep } from '@/models/datasets'
import { PipelineInputVarType } from '@/models/pipeline'
import { expectLoadingButton } from '@/test/button'
import Options from '../index'
const { mockToastError } = vi.hoisted(() => ({
@ -257,12 +258,12 @@ describe('Options', () => {
expect(screen.getByText(/running/i)).toBeInTheDocument()
})
it('should disable button when step is running', () => {
it('should keep button loading-disabled when step is running', () => {
const props = createDefaultProps({ step: CrawlStep.running })
render(<Options {...props} />)
expect(screen.getByRole('button')).toBeDisabled()
expectLoadingButton(screen.getByRole('button'))
})
it('should enable button when step is finished', () => {
@ -272,16 +273,6 @@ describe('Options', () => {
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should show loading state on button when step is running', () => {
const props = createDefaultProps({ step: CrawlStep.running })
render(<Options {...props} />)
// Assert - Button should have loading prop which disables it
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
})
describe('runDisabled prop', () => {
@ -306,7 +297,7 @@ describe('Options', () => {
render(<Options {...props} />)
expect(screen.getByRole('button')).toBeDisabled()
expectLoadingButton(screen.getByRole('button'))
})
it('should default runDisabled to undefined (falsy)', () => {
@ -495,9 +486,8 @@ describe('Options', () => {
render(<Options {...props} />)
// Assert - Button should be in loading state
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expectLoadingButton(button)
expect(screen.getByText(/running/i)).toBeInTheDocument()
})
@ -772,7 +762,9 @@ describe('Options', () => {
render(<Options {...props} />)
const button = screen.getByRole('button')
if (expectedDisabled)
if (propVariation.step === CrawlStep.running)
expectLoadingButton(button)
else if (expectedDisabled)
expect(button).toBeDisabled()
else
expect(button).not.toBeDisabled()

View File

@ -3,6 +3,7 @@ import type { Query } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
import QueryInput from '../index'
// Capture onChange callback so tests can trigger handleImageChange
@ -123,8 +124,7 @@ describe('QueryInput', () => {
it('should show loading state on submit button when loading', () => {
render(<QueryInput {...defaultProps} loading={true} />)
const submitButton = screen.getByRole('button', { name: /input\.testing/ })
expect(submitButton)!.toBeDisabled()
expect(submitButton)!.toHaveAttribute('aria-busy', 'true')
expectLoadingButton(submitButton)
expect(submitButton.querySelector('.animate-spin'))!.toBeInTheDocument()
})

View File

@ -2,6 +2,7 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { expectLoadingButton } from '@/test/button'
import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../create/step-two'
import Form from '../index'
@ -381,9 +382,8 @@ describe('Form', () => {
const saveButton = screen.getByRole('button', { name: /form\.save/i })
fireEvent.click(saveButton)
// Button should be disabled during loading
await waitFor(() => {
expect(saveButton).toBeDisabled()
expectLoadingButton(saveButton)
})
})

View File

@ -1,6 +1,7 @@
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MediaType } from '@/hooks/use-breakpoints'
import { expectLoadingButton } from '@/test/button'
import { AppModeEnum } from '@/types/app'
import SideBar from '../index'
@ -224,7 +225,7 @@ describe('SideBar', () => {
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
expect(screen.getByText('common.operation.cancel')).toBeDisabled()
expect(screen.getByText('common.operation.confirm')).toBeDisabled()
expectLoadingButton(screen.getByText('common.operation.confirm').closest('button'))
})
})

View File

@ -8,6 +8,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
import { expectLoadingButton } from '@/test/button'
import { downloadUrl } from '@/utils/download'
import Compliance from '../compliance'
@ -253,7 +254,7 @@ describe('Compliance', () => {
await waitFor(() => {
const busyButton = menuItem!.querySelector('button[aria-busy="true"]')
expect(busyButton).not.toBeNull()
expect(busyButton)!.toBeDisabled()
expectLoadingButton(busyButton)
expect(busyButton!.querySelector('.animate-spin')).not.toBeNull()
}, { timeout: 10000 })
@ -288,7 +289,7 @@ describe('Compliance', () => {
await waitFor(() => {
const busyButton = menuItem!.querySelector('button[aria-busy="true"]')
expect(busyButton).not.toBeNull()
expect(busyButton)!.toBeDisabled()
expectLoadingButton(busyButton)
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
}, { timeout: 10000 })

View File

@ -2,6 +2,7 @@ import type { PluginDetail } from '../../../../types'
import type { ModalStates, VersionTarget } from '../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
import { PluginSource } from '../../../../types'
import HeaderModals from '../header-modals'
@ -301,7 +302,7 @@ describe('HeaderModals', () => {
/>,
)
expect(screen.getByRole('button', { name: /common\.operation\.confirm/ })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: /common\.operation\.confirm/ }))
})
})

View File

@ -1,6 +1,7 @@
import type { MetaData, PluginCategoryEnum } from '../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
// ==================== Imports (after mocks) ====================
@ -408,7 +409,7 @@ describe('Action Component', () => {
// Assert - Loading state
await waitFor(() => {
expect(getDeleteConfirmButton())!.toBeDisabled()
expectLoadingButton(getDeleteConfirmButton())
})
// Resolve and check modal closes
@ -865,7 +866,7 @@ describe('Action Component', () => {
// The confirm button should be disabled during deletion
// The confirm button should be disabled during deletion
expect(getDeleteConfirmButton())!.toBeDisabled()
expectLoadingButton(getDeleteConfirmButton())
// Resolve the deletion
resolveFirst!({ success: true })

View File

@ -2,6 +2,7 @@ import type { Plugin } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
import { PluginCategoryEnum } from '../../types'
import PluginMutationModal from '../index'
@ -433,13 +434,11 @@ describe('PluginMutationModal', () => {
render(<PluginMutationModal {...props} />)
const confirmButton = screen.getByRole('button', { name: /Confirm/i })
expect(confirmButton).toBeDisabled()
expectLoadingButton(confirmButton)
fireEvent.click(confirmButton)
// Button is disabled, so mutate might still be called depending on implementation
// The important thing is the button has disabled attribute
expect(confirmButton).toHaveAttribute('disabled')
expect(mutate).not.toHaveBeenCalled()
})
})
@ -468,18 +467,7 @@ describe('PluginMutationModal', () => {
render(<PluginMutationModal {...props} />)
const confirmButton = screen.getByRole('button', { name: /Confirm/i })
expect(confirmButton).toBeDisabled()
})
it('should disable confirm button', () => {
const props = createDefaultProps({
mutation: createMockMutation({ isPending: true }),
})
render(<PluginMutationModal {...props} />)
const confirmButton = screen.getByRole('button', { name: /Confirm/i })
expect(confirmButton).toBeDisabled()
expectLoadingButton(confirmButton)
})
})

View File

@ -1,6 +1,7 @@
import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
import { fireEvent, render, screen } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { expectLoadingButton } from '@/test/button'
import Actions from '../actions'
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined
@ -62,6 +63,6 @@ describe('Document processing actions', () => {
/>,
)
expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i }))
})
})

View File

@ -8,6 +8,7 @@ import * as React from 'react'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { expectLoadingButton } from '@/test/button'
import Actions from '../actions'
import DocumentProcessing from '../index'
import Options from '../options'
@ -566,7 +567,7 @@ describe('Actions', () => {
)
const processButton = screen.getByText('datasetPipeline.operations.process').closest('button')
expect(processButton).toBeDisabled()
expectLoadingButton(processButton)
})
it('should disable process button when canSubmit is false', () => {
@ -597,7 +598,7 @@ describe('Actions', () => {
)
const processButton = screen.getByText('datasetPipeline.operations.process').closest('button')
expect(processButton).toBeDisabled()
expectLoadingButton(processButton)
})
it('should enable process button when all conditions are met', () => {
@ -653,39 +654,6 @@ describe('Actions', () => {
})
})
describe('Loading State', () => {
it('should show loading state when isSubmitting', () => {
const mockFormParams = createMockFormParams({ isSubmitting: true })
render(
<Actions
formParams={mockFormParams}
onBack={vi.fn()}
/>,
)
const processButton = screen.getByText('datasetPipeline.operations.process').closest('button')
expect(processButton).toBeDisabled()
})
it('should show loading state when workflow is running', () => {
mockWorkflowRunningData = {
result: { status: WorkflowRunningStatus.Running },
}
const mockFormParams = createMockFormParams()
render(
<Actions
formParams={mockFormParams}
onBack={vi.fn()}
/>,
)
const processButton = screen.getByText('datasetPipeline.operations.process').closest('button')
expect(processButton).toBeDisabled()
})
})
describe('Edge Cases', () => {
it('should handle undefined runDisabled prop', () => {
const mockFormParams = createMockFormParams()
@ -1297,7 +1265,7 @@ describe('DocumentProcessing Integration', () => {
)
const processButton = screen.getByText('datasetPipeline.operations.process').closest('button')
expect(processButton).toBeDisabled()
expectLoadingButton(processButton)
})
it('should update when fetching params status changes', () => {

View File

@ -2,6 +2,7 @@ import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PipelineInputVarType } from '@/models/pipeline'
import { expectLoadingButton } from '@/test/button'
import CreateSnippetDialog from '../create-snippet-dialog'
let capturedKeyPressHandler: (() => void) | undefined
@ -182,6 +183,6 @@ describe('CreateSnippetDialog', () => {
expect(screen.getByPlaceholderText('workflow.snippet.namePlaceholder')).toBeDisabled()
expect(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder')).toBeDisabled()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'workflow.snippet.confirm' })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: 'workflow.snippet.confirm' }))
})
})

View File

@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expectLoadingButton } from '@/test/button'
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
describe('SaveBeforeLeavingDialog', () => {
@ -40,6 +41,6 @@ describe('SaveBeforeLeavingDialog', () => {
expect(screen.getByRole('button', { name: 'snippet.continueEditing' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'snippet.saveAndExit' })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
})
})

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { expectLoadingButton } from '@/test/button'
import SnippetHeader from '..'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
@ -223,7 +224,7 @@ describe('SnippetHeader', () => {
)
expect(screen.getByRole('button', { name: 'snippet.exitEditing' })).toBeDisabled()
expect(screen.getByRole('button', { name: /^snippet\.save$/i })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: /^snippet\.save$/i }))
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
})

View File

@ -1,5 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expectLoadingButton } from '@/test/button'
import DSLExportConfirmModal from '../dsl-export-confirm-modal'
const envList = [
@ -125,7 +126,7 @@ describe('DSLExportConfirmModal', () => {
const firstClick = user.click(confirmButton)
await waitFor(() => {
expect(confirmButton).toBeDisabled()
expectLoadingButton(confirmButton)
expect(confirmButton).toHaveTextContent('common.operation.exporting')
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
})

View File

@ -3,6 +3,7 @@ import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/c
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { expectLoadingButton } from '@/test/button'
import { encryptPassword } from '@/utils/encryption'
import InstallForm from './installForm'
@ -135,7 +136,7 @@ describe('InstallForm', () => {
fireEvent.click(button)
await waitFor(() => {
expect(button).toBeDisabled()
expectLoadingButton(button)
})
fireEvent.click(button)

8
web/test/button.ts Normal file
View File

@ -0,0 +1,8 @@
import { expect } from 'vitest'
export const expectLoadingButton = (button: Element | null) => {
expect(button).toBeInstanceOf(HTMLButtonElement)
expect(button).toHaveAttribute('aria-busy', 'true')
expect(button).toHaveAttribute('aria-disabled', 'true')
expect(button).not.toHaveAttribute('disabled')
}