fix(a11y): make balance visibility toggle keyboard-accessible (#1412)

This commit is contained in:
GuTS805 2026-08-10 00:35:49 +05:30 committed by GitHub
parent 8f9d8d5917
commit 6022ed3d41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 94 additions and 18 deletions

View file

@ -5,6 +5,7 @@ import user from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Balance } from '@/components/ui/jam/Balance'
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
import '@/i18n/config'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
@ -221,4 +222,51 @@ describe('<Balance />', () => {
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should render the visibility toggle as a keyboard-focusable button with an accessible name', () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
const toggleButton = screen.getByRole('button', { name: 'Show balance' })
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveAttribute('aria-pressed', 'true')
})
it('should update the accessible name and aria-pressed after toggling', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.click(screen.getByRole('button', { name: 'Show balance' }))
const toggleButton = screen.getByRole('button', { name: 'Hide balance' })
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveAttribute('aria-pressed', 'false')
})
it('should toggle visibility via keyboard (Enter)', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.tab()
expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should toggle visibility via keyboard (Space)', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.tab()
expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus()
await user.keyboard(' ')
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should not render a focusable button when the visibility toggle is disabled', () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} enableVisibilityToggle={false} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type MouseEvent, type MouseEventHandler, type PropsWithChildren } from 'react'
import { SnowflakeIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { useJamDisplayContext } from '@/context/JamDisplayContext'
import { cn, satsToBtc, tryBtcToSat, isValidNumber, getBtcParts, formatSats } from '@/lib/utils'
@ -23,6 +24,8 @@ type ElementWithSymbolsProps = PropsWithChildren<{
frozenSymbol?: boolean
className?: string
onClick?: MouseEventHandler
'aria-label'?: string
'aria-pressed'?: boolean
}>
const ElementWithSymbols = ({
@ -33,23 +36,40 @@ const ElementWithSymbols = ({
className,
children,
onClick,
'aria-label': ariaLabel,
'aria-pressed': ariaPressed,
}: ElementWithSymbolsProps) => {
return (
<span
className={cn(
'balance-hook inline-flex items-center',
{
'text-brand-info': frozen,
},
className,
)}
onClick={onClick}
>
const sharedClassName = cn(
'balance-hook inline-flex items-center',
{
'text-brand-info': frozen,
},
className,
)
const content = (
<>
{children}
{showSymbol && symbol}
{frozen && frozenSymbol && FROZEN_SYMBOL}
</span>
</>
)
if (onClick) {
return (
<button
type="button"
className={cn(sharedClassName, 'appearance-none border-0 bg-transparent p-0')}
onClick={onClick}
aria-label={ariaLabel}
aria-pressed={ariaPressed}
>
{content}
</button>
)
}
return <span className={sharedClassName}>{content}</span>
}
const DECIMAL_POINT_CHAR = '.'
@ -156,6 +176,7 @@ export const BalanceComponent = ({
enableVisibilityToggle,
...props
}: BalanceComponentProps) => {
const { t } = useTranslation()
const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
const displayMode = useMemo<DisplayMode>(() => {
return isBalanceVisible ? (convertToUnit ?? 'default') : 'hidden'
@ -173,18 +194,25 @@ export const BalanceComponent = ({
setIsBalanceVisible((current) => !current)
}
const onClickHandler = enableVisibilityToggle === false ? undefined : toggleVisibility
const isInteractive = Boolean(onClickHandler || props.onClick)
return {
...props,
className: cn(props.className, {
'cursor-pointer': onClickHandler || props.onClick,
'cursor-pointer': isInteractive,
}),
onClick: (event: MouseEvent<HTMLSpanElement>) => {
onClickHandler?.(event)
props.onClick?.(event)
},
onClick: isInteractive
? (event: MouseEvent<HTMLButtonElement>) => {
onClickHandler?.(event)
props.onClick?.(event)
}
: undefined,
'aria-label': onClickHandler
? t(isBalanceVisible ? 'settings.hide_balance' : 'settings.show_balance')
: undefined,
'aria-pressed': onClickHandler ? !isBalanceVisible : undefined,
}
}, [props, enableVisibilityToggle])
}, [props, enableVisibilityToggle, isBalanceVisible, t])
const element = useMemo(() => {
if (displayMode === 'hidden') {