fix(a11y): add keyboard accessibility to HomeScreen profile list

- Profile list rows are now focusable (tabIndex) and operable via
  Enter/Space (onKeyDown handler)
- Added role=listbox on the <ul> and role=option + aria-selected on
  non-active rows
- Added descriptive aria-label including profile name and active state
- Added focus-visible CSS ring matching the design system pattern
- Updated test role from 'list' to 'listbox'
This commit is contained in:
Avi 2026-09-01 11:07:42 -05:00
commit e9022b36b9
3 changed files with 24 additions and 2 deletions

View file

@ -116,11 +116,15 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</Button> </Button>
</header> </header>
<div className="card-body"> <div className="card-body">
<ul className="home-profile-list"> <ul className="home-profile-list" role="listbox" aria-label="Profiles">
{state?.profiles.map((profile) => ( {state?.profiles.map((profile) => (
<li <li
key={profile.npub} key={profile.npub}
className={`home-profile-row${profile.is_active ? ' is-active' : ''}`} className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
role={profile.is_active ? undefined : 'option'}
aria-selected={profile.is_active}
tabIndex={profile.is_active ? undefined : 0}
aria-label={`${profile.label}${profile.is_active ? ' (active)' : ''} — select profile`}
onClick={ onClick={
profile.is_active profile.is_active
? undefined ? undefined
@ -131,6 +135,19 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
void onSelect(profile.npub); void onSelect(profile.npub);
} }
} }
onKeyDown={
profile.is_active
? undefined
: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if ((event.target as HTMLElement).closest('button, a, input')) {
return;
}
void onSelect(profile.npub);
}
}
}
> >
<Avatar <Avatar
npub={profile.npub} npub={profile.npub}

View file

@ -1557,6 +1557,11 @@ select {
cursor: pointer; cursor: pointer;
} }
.home-profile-row:not(.is-active):focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.home-profile-row .active-profile-meta { .home-profile-row .active-profile-meta {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View file

@ -41,7 +41,7 @@ describe('HomeScreen', () => {
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
// The active profile row carries the "Selected" badge; find Alice via the profile list. // The active profile row carries the "Selected" badge; find Alice via the profile list.
const profileList = await screen.findByRole('list'); const profileList = await screen.findByRole('listbox');
const aliceRow = within(profileList) const aliceRow = within(profileList)
.getByText('Alice') .getByText('Alice')
.closest('.home-profile-row') as HTMLElement; .closest('.home-profile-row') as HTMLElement;