mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
fix(a11y): make balance visibility toggle keyboard-accessible (#1412)
This commit is contained in:
parent
8f9d8d5917
commit
6022ed3d41
2 changed files with 94 additions and 18 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue