From 6022ed3d41c20466afa9bccb1e27414ef2cb4e6e Mon Sep 17 00:00:00 2001 From: GuTS805 Date: Mon, 10 Aug 2026 00:35:49 +0530 Subject: [PATCH] fix(a11y): make balance visibility toggle keyboard-accessible (#1412) --- src/components/ui/jam/Balance.test.tsx | 48 +++++++++++++++++++ src/components/ui/jam/Balance.tsx | 64 ++++++++++++++++++-------- 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/src/components/ui/jam/Balance.test.tsx b/src/components/ui/jam/Balance.test.tsx index 0915d358..49d0355a 100644 --- a/src/components/ui/jam/Balance.test.tsx +++ b/src/components/ui/jam/Balance.test.tsx @@ -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) => { @@ -221,4 +222,51 @@ describe('', () => { 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() + + 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() + + 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() + + 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() + + 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() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) }) diff --git a/src/components/ui/jam/Balance.tsx b/src/components/ui/jam/Balance.tsx index 11740b9a..c61215cd 100644 --- a/src/components/ui/jam/Balance.tsx +++ b/src/components/ui/jam/Balance.tsx @@ -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 ( - + const sharedClassName = cn( + 'balance-hook inline-flex items-center', + { + 'text-brand-info': frozen, + }, + className, + ) + + const content = ( + <> {children} {showSymbol && symbol} {frozen && frozenSymbol && FROZEN_SYMBOL} - + ) + + if (onClick) { + return ( + + ) + } + + return {content} } 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(() => { 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) => { - onClickHandler?.(event) - props.onClick?.(event) - }, + onClick: isInteractive + ? (event: MouseEvent) => { + 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') {