(frontend) improve screen reader support in DocShare modal

adds relevant aria-labels to enhance accessibility for assistive technologies

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-11-18 15:35:07 +01:00
parent f7baf238e3
commit 5f9968d81e
13 changed files with 69 additions and 23 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- ♿(frontend) improve accessibility: - ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626 - ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
- 🐛(frontend) fix toolbar not activated when reader #1640 - 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588 - 🐛(frontend) preserve left panel width on window resize #1588
@@ -52,7 +53,6 @@ and this project adheres to
- 🔥(backend) remove api managing templates - 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10 ## [3.9.0] - 2025-11-10
### Added ### Added

View File

@@ -208,7 +208,7 @@ test.describe('Doc Header', () => {
await expect( await expect(
invitationCard.getByText('test.test@invitation.test').first(), invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible(); ).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown'); const invitationRole = invitationCard.getByTestId('doc-role-dropdown');
await expect(invitationRole).toBeVisible(); await expect(invitationRole).toBeVisible();
await invitationRole.click(); await invitationRole.click();
@@ -217,7 +217,7 @@ test.describe('Doc Header', () => {
await expect(invitationCard).toBeHidden(); await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card'); const memberCard = shareModal.getByLabel('List members card');
const roles = memberCard.getByLabel('doc-role-dropdown'); const roles = memberCard.getByTestId('doc-role-dropdown');
await expect(memberCard).toBeVisible(); await expect(memberCard).toBeVisible();
await expect( await expect(
memberCard.getByText('test.test@accesses.test').first(), memberCard.getByText('test.test@accesses.test').first(),

View File

@@ -74,7 +74,7 @@ test.describe('Document create member', () => {
await expect(list.getByText(email)).toBeVisible(); await expect(list.getByText(email)).toBeVisible();
// Check roles are displayed // Check roles are displayed
await list.getByLabel('doc-role-dropdown').click(); await list.getByTestId('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
@@ -128,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role // Choose a role
const container = page.getByTestId('doc-share-add-member-list'); const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click(); await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse( const responsePromiseCreateInvitation = page.waitForResponse(
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
await page.getByTestId(`search-user-row-${email}`).click(); await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role // Choose a role
await container.getByLabel('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click(); await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse( const responsePromiseCreateInvitationFail = page.waitForResponse(
@@ -183,7 +183,7 @@ test.describe('Document create member', () => {
// Choose a role // Choose a role
const container = page.getByTestId('doc-share-add-member-list'); const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse( const responsePromiseCreateInvitation = page.waitForResponse(
@@ -210,7 +210,7 @@ test.describe('Document create member', () => {
response.request().method() === 'PATCH', response.request().method() === 'PATCH',
); );
await userInvitation.getByLabel('doc-role-dropdown').click(); await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click(); await page.getByRole('menuitem', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation; const responsePatchInvitation = await responsePromisePatchInvitation;
@@ -272,7 +272,7 @@ test.describe('Document create member', () => {
const container = page.getByTestId( const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`, `doc-share-access-request-row-${emailRequest}`,
); );
await container.getByLabel('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click(); await container.getByRole('button', { name: 'Approve' }).click();

View File

@@ -152,7 +152,7 @@ test.describe('Document list members', () => {
const currentUser = list.getByTestId( const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`, `doc-share-member-row-user.test@${browserName}.test`,
); );
const currentUserRole = currentUser.getByLabel('doc-role-dropdown'); const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await expect(currentUser).toBeVisible(); await expect(currentUser).toBeVisible();
await expect(currentUserRole).toBeVisible(); await expect(currentUserRole).toBeVisible();
await currentUserRole.click(); await currentUserRole.click();
@@ -169,7 +169,7 @@ test.describe('Document list members', () => {
}); });
const newUserEmail = await addNewMember(page, 0, 'Owner'); const newUserEmail = await addNewMember(page, 0, 'Owner');
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`); const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
const newUserRoles = newUser.getByLabel('doc-role-dropdown'); const newUserRoles = newUser.getByTestId('doc-role-dropdown');
await expect(newUser).toBeVisible(); await expect(newUser).toBeVisible();
@@ -214,9 +214,7 @@ test.describe('Document list members', () => {
const emailMyself = `user.test@${browserName}.test`; const emailMyself = `user.test@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`); const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByRole('button', { const mySelfRole = mySelf.getByTestId('doc-role-dropdown');
name: 'doc-role-dropdown',
});
const userOwnerEmail = await addNewMember(page, 0, 'Owner'); const userOwnerEmail = await addNewMember(page, 0, 'Owner');
const userOwner = list.getByTestId( const userOwner = list.getByTestId(
@@ -231,9 +229,7 @@ test.describe('Document list members', () => {
const userReader = list.getByTestId( const userReader = list.getByTestId(
`doc-share-member-row-${userReaderEmail}`, `doc-share-member-row-${userReaderEmail}`,
); );
const userReaderRole = userReader.getByRole('button', { const userReaderRole = userReader.getByTestId('doc-role-dropdown');
name: 'doc-role-dropdown',
});
await expect(mySelf).toBeVisible(); await expect(mySelf).toBeVisible();
await expect(userOwner).toBeVisible(); await expect(userOwner).toBeVisible();

View File

@@ -226,7 +226,7 @@ test.describe('Doc Tree', () => {
const currentUser = list.getByTestId( const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`, `doc-share-member-row-user.test@${browserName}.test`,
); );
const currentUserRole = currentUser.getByLabel('doc-role-dropdown'); const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click(); await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click(); await list.click();

View File

@@ -38,9 +38,9 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click(); await page.getByRole('option', { name: users[index].email }).click();
// Choose a role // Choose a role
await page.getByLabel('doc-role-dropdown').click(); await page.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click(); await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: /^Invite / }).click(); await page.getByTestId('doc-share-invite-button').click();
return users[index].email; return users[index].email;
}; };
@@ -74,7 +74,7 @@ export const updateRoleUser = async (
const list = page.getByTestId('doc-share-quick-search'); const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(`doc-share-member-row-${email}`); const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown'); const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click(); await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click(); await page.getByRole('menuitem', { name: role }).click();
await list.click(); await list.click();

View File

@@ -69,7 +69,7 @@ export const QuickSearch = ({
label={label} label={label}
shouldFilter={false} shouldFilter={false}
ref={ref} ref={ref}
tabIndex={0} tabIndex={-1}
value={selectedValue} value={selectedValue}
onValueChange={handleValueChange} onValueChange={handleValueChange}
> >

View File

@@ -18,6 +18,7 @@ type DocRoleDropdownProps = {
onSelectRole: (role: Role) => void; onSelectRole: (role: Role) => void;
rolesAllowed?: Role[]; rolesAllowed?: Role[];
isLastOwner?: boolean; isLastOwner?: boolean;
ariaLabel?: string;
}; };
export const DocRoleDropdown = ({ export const DocRoleDropdown = ({
@@ -29,6 +30,7 @@ export const DocRoleDropdown = ({
rolesAllowed, rolesAllowed,
access, access,
isLastOwner = false, isLastOwner = false,
ariaLabel,
}: DocRoleDropdownProps) => { }: DocRoleDropdownProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { transRole, translatedRoles } = useTrans(); const { transRole, translatedRoles } = useTrans();
@@ -113,11 +115,15 @@ export const DocRoleDropdown = ({
return ( return (
<DropdownMenu <DropdownMenu
topMessage={topMessage} topMessage={topMessage}
label="doc-role-dropdown" label={t('{{action}}, current role: {{role}}', {
action: ariaLabel,
role: transRole(currentRole),
})}
showArrow={true} showArrow={true}
arrowCss={css` arrowCss={css`
color: var(--c--theme--colors--primary-800) !important; color: var(--c--theme--colors--primary-800) !important;
`} `}
testId="doc-role-dropdown"
options={[ options={[
...roles, ...roles,
{ {

View File

@@ -78,6 +78,9 @@ const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => {
onSelectRole={setRole} onSelectRole={setRole}
canUpdate={doc.abilities.accesses_manage} canUpdate={doc.abilities.accesses_manage}
rolesAllowed={accessRequest.abilities.set_role_to} rolesAllowed={accessRequest.abilities.set_role_to}
ariaLabel={t('Change role for {{name}}', {
name: accessRequest.user.full_name || accessRequest.user.email,
})}
/> />
<Button <Button
color="tertiary" color="tertiary"

View File

@@ -153,6 +153,7 @@ export const DocShareAddMemberList = ({
onClick={() => void onInvite()} onClick={() => void onInvite()}
disabled={isLoading} disabled={isLoading}
aria-label={inviteLabel} aria-label={inviteLabel}
data-testid="doc-share-invite-button"
> >
{t('Invite')} {t('Invite')}
</Button> </Button>

View File

@@ -112,6 +112,9 @@ export const DocShareInvitationItem = ({
canUpdate={canUpdate} canUpdate={canUpdate}
doc={doc} doc={doc}
access={invitation} access={invitation}
ariaLabel={t('Change role for {{email}}', {
email: invitation.email,
})}
/> />
{canUpdate && ( {canUpdate && (

View File

@@ -77,6 +77,9 @@ export const DocShareMemberItem = ({
rolesAllowed={access.abilities.set_role_to} rolesAllowed={access.abilities.set_role_to}
access={access} access={access}
doc={doc} doc={doc}
ariaLabel={t('Change role for {{name}}', {
name: access.user.full_name || access.user.email,
})}
/> />
</Box> </Box>
} }

View File

@@ -76,6 +76,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const [selectedUsers, setSelectedUsers] = useState<User[]>([]); const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [userQuery, setUserQuery] = useState(''); const [userQuery, setUserQuery] = useState('');
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [liveAnnouncement, setLiveAnnouncement] = useState('');
const [listHeight, setListHeight] = useState<string>('400px'); const [listHeight, setListHeight] = useState<string>('400px');
const canShare = doc.abilities.accesses_manage && isRootDoc; const canShare = doc.abilities.accesses_manage && isRootDoc;
@@ -88,6 +89,19 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
setSelectedUsers((prev) => [...prev, user]); setSelectedUsers((prev) => [...prev, user]);
setUserQuery(''); setUserQuery('');
setInputValue(''); setInputValue('');
// Announce to screen readers
const userName = user.full_name || user.email;
setLiveAnnouncement(
t(
'{{name}} added to invite list. Add more members or press Tab to select role and invite.',
{
name: userName,
},
),
);
// Clear announcement after it's been read
setTimeout(() => setLiveAnnouncement(''), 100);
}; };
const { data: membersQuery } = useDocAccesses({ const { data: membersQuery } = useDocAccesses({
@@ -114,6 +128,16 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
} }
const newArray = [...prevState]; const newArray = [...prevState];
newArray.splice(index, 1); newArray.splice(index, 1);
// Announce to screen readers
const userName = row.full_name || row.email;
setLiveAnnouncement(
t('{{name}} removed from invite list', {
name: userName,
}),
);
setTimeout(() => setLiveAnnouncement(''), 100);
return newArray; return newArray;
}); });
}; };
@@ -175,12 +199,22 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
<ButtonCloseModal <ButtonCloseModal
aria-label={t('Close the share modal')} aria-label={t('Close the share modal')}
onClick={onClose} onClick={onClose}
tabIndex={-1}
/> />
</Box> </Box>
} }
hideCloseButton hideCloseButton
> >
<ShareModalStyle /> <ShareModalStyle />
{/* Screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{liveAnnouncement}
</div>
<Box <Box
$height="auto" $height="auto"
$maxHeight={canViewAccesses ? modalContentHeight : 'none'} $maxHeight={canViewAccesses ? modalContentHeight : 'none'}