✨(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:
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
Reference in New Issue
Block a user