fix(a11y): improved aria-label for status and notifications (#690)

* fix(a11y): improved aria-label for status and notifications

fixes #689

* only calculate formatted date once

* fixup tests

*  fixup tests more

* fixup

* fixup tests again
This commit is contained in:
Nolan Lawson 2018-11-25 01:20:58 -08:00 committed by GitHub
parent 2db06ea472
commit cc81a7bec6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 203 additions and 41 deletions

View File

@ -143,7 +143,8 @@
"Element", "Element",
"Image", "Image",
"NotificationEvent", "NotificationEvent",
"NodeList" "NodeList",
"DOMParser"
], ],
"ignore": [ "ignore": [
"dist", "dist",

View File

@ -0,0 +1,49 @@
import { getAccountAccessibleName } from './getAccountAccessibleName'
import { htmlToPlainText } from '../_utils/htmlToPlainText'
import { POST_PRIVACY_OPTIONS } from '../_static/statuses'
function notificationText (notification, omitEmojiInDisplayNames) {
if (!notification) {
return
}
let notificationAccountDisplayName = getAccountAccessibleName(notification.account, omitEmojiInDisplayNames)
if (notification.type === 'reblog') {
return `${notificationAccountDisplayName} boosted your status`
} else if (notification.type === 'favourite') {
return `${notificationAccountDisplayName} favorited your status`
}
}
function privacyText (visibility) {
for (let option of POST_PRIVACY_OPTIONS) {
if (option.key === visibility) {
return option.label
}
}
}
function reblogText (reblog, account, omitEmojiInDisplayNames) {
if (!reblog) {
return
}
let accountDisplayName = getAccountAccessibleName(account, omitEmojiInDisplayNames)
return `Boosted by ${accountDisplayName}`
}
export function getAccessibleLabelForStatus (originalAccount, account, content,
timeagoFormattedDate, spoilerText, showContent,
reblog, notification, visibility, omitEmojiInDisplayNames) {
let originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames)
let values = [
notificationText(notification, omitEmojiInDisplayNames),
originalAccountDisplayName,
(showContent || !spoilerText) ? htmlToPlainText(content) : `Content warning: ${spoilerText}`,
timeagoFormattedDate,
`@${originalAccount.acct}`,
privacyText(visibility),
reblogText(reblog, account, omitEmojiInDisplayNames)
].filter(Boolean)
return values.join(', ')
}

View File

@ -0,0 +1,10 @@
import { removeEmoji } from '../_utils/removeEmoji'
export function getAccountAccessibleName (account, omitEmojiInDisplayNames) {
let emojis = account.emojis
let displayName = account.display_name || account.username
if (omitEmojiInDisplayNames) {
displayName = removeEmoji(displayName, emojis) || displayName
}
return displayName
}

View File

@ -6,7 +6,9 @@
<article class="notification-article" <article class="notification-article"
tabindex="0" tabindex="0"
aria-posinset={index} aria-posinset={index}
aria-setsize={length} > aria-setsize={length}
aria-label={ariaLabel}
>
<StatusHeader {notification} {notificationId} {status} {statusId} {timelineType} <StatusHeader {notification} {notificationId} {status} {statusId} {timelineType}
{account} {accountId} {uuid} isStatusInNotification="true" /> {account} {accountId} {uuid} isStatusInNotification="true" />
</article> </article>
@ -30,6 +32,7 @@
import Status from './Status.html' import Status from './Status.html'
import StatusHeader from './StatusHeader.html' import StatusHeader from './StatusHeader.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { getAccountAccessibleName } from '../../_a11y/getAccountAccessibleName'
export default { export default {
components: { components: {
@ -45,7 +48,10 @@
statusId: ({ status }) => status && status.id, statusId: ({ status }) => status && status.id,
uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => { uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => {
return `${$currentInstance}/${timelineType}/${timelineValue}/${notificationId}/${statusId || ''}` return `${$currentInstance}/${timelineType}/${timelineValue}/${notificationId}/${statusId || ''}`
} },
ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => (
!status && `${getAccountAccessibleName(account, $omitEmojiInDisplayNames)} followed you, @${account.acct}`
)
} }
} }
</script> </script>

View File

@ -110,7 +110,9 @@
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
import { checkDomAncestors } from '../../_utils/checkDomAncestors' import { checkDomAncestors } from '../../_utils/checkDomAncestors'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { removeEmoji } from '../../_utils/removeEmoji' import { getAccountAccessibleName } from '../../_a11y/getAccountAccessibleName'
import { getAccessibleLabelForStatus } from '../../_a11y/getAccessibleLabelForStatus'
import { formatTimeagoDate } from '../../_intl/formatTimeagoDate'
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea']) const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea'])
const isUserInputElement = node => INPUT_TAGS.has(node.localName) const isUserInputElement = node => INPUT_TAGS.has(node.localName)
@ -211,16 +213,17 @@
), ),
originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []), originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []),
originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username), originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username),
originalAccountAccessibleName: ({ originalAccountDisplayName, originalAccountEmojis, $omitEmojiInDisplayNames }) => { originalAccountAccessibleName: ({ originalAccount, $omitEmojiInDisplayNames }) => {
if ($omitEmojiInDisplayNames) { return getAccountAccessibleName(originalAccount, $omitEmojiInDisplayNames)
return removeEmoji(originalAccountDisplayName, originalAccountEmojis) || originalAccountDisplayName
}
return originalAccountDisplayName
}, },
ariaLabel: ({ originalAccountAccessibleName, originalStatus, visibility, isStatusInOwnThread }) => ( createdAtDate: ({ originalStatus }) => originalStatus.created_at,
(visibility === 'direct' ? 'Direct message' : 'Status') + timeagoFormattedDate: ({ createdAtDate }) => formatTimeagoDate(createdAtDate),
` by ${originalAccountAccessibleName}` + reblog: ({ status }) => status.reblog,
(isStatusInOwnThread ? ' (focused)' : '') ariaLabel: ({ originalAccount, account, content, timeagoFormattedDate, spoilerText,
showContent, reblog, notification, visibility, $omitEmojiInDisplayNames }) => (
getAccessibleLabelForStatus(originalAccount, account, content,
timeagoFormattedDate, spoilerText, showContent,
reblog, notification, visibility, $omitEmojiInDisplayNames)
), ),
showHeader: ({ notification, status, timelineType }) => ( showHeader: ({ notification, status, timelineType }) => (
(notification && (notification.type === 'reblog' || notification.type === 'favourite')) || (notification && (notification.type === 'reblog' || notification.type === 'favourite')) ||
@ -238,7 +241,8 @@
params: ({ notification, notificationId, status, statusId, timelineType, params: ({ notification, notificationId, status, statusId, timelineType,
account, accountId, uuid, isStatusInNotification, isStatusInOwnThread, account, accountId, uuid, isStatusInNotification, isStatusInOwnThread,
originalAccount, originalAccountId, spoilerShown, visibility, replyShown, originalAccount, originalAccountId, spoilerShown, visibility, replyShown,
replyVisibility, spoilerText, originalStatus, originalStatusId, inReplyToId }) => ({ replyVisibility, spoilerText, originalStatus, originalStatusId, inReplyToId,
createdAtDate, timeagoFormattedDate }) => ({
notification, notification,
notificationId, notificationId,
status, status,
@ -258,7 +262,9 @@
spoilerText, spoilerText,
originalStatus, originalStatus,
originalStatusId, originalStatusId,
inReplyToId inReplyToId,
createdAtDate,
timeagoFormattedDate
}) })
} }
} }

View File

@ -2,9 +2,9 @@
href="/statuses/{originalStatusId}" href="/statuses/{originalStatusId}"
focus-key={focusKey} focus-key={focusKey}
> >
<time datetime={createdAtDate} title={relativeDate} <time datetime={createdAtDate} title={timeagoFormattedDate}
aria-label="{relativeDate} click to show thread"> aria-label="{timeagoFormattedDate} click to show thread">
{relativeDate} {timeagoFormattedDate}
</time> </time>
</a> </a>
<style> <style>
@ -29,19 +29,8 @@
</style> </style>
<script> <script>
import { mark, stop } from '../../_utils/marks'
import timeago from 'timeago.js'
const timeagoInstance = timeago()
export default { export default {
computed: { computed: {
createdAtDate: ({ originalStatus }) => originalStatus.created_at,
relativeDate: ({ createdAtDate }) => {
mark('compute relativeDate')
let res = timeagoInstance.format(createdAtDate)
stop('compute relativeDate')
return res
},
focusKey: ({ uuid }) => `status-relative-date-${uuid}` focusKey: ({ uuid }) => `status-relative-date-${uuid}`
} }
} }

View File

@ -0,0 +1,11 @@
import timeago from 'timeago.js'
import { mark, stop } from '../_utils/marks'
const timeagoInstance = timeago()
export function formatTimeagoDate (date) {
mark('formatTimeagoDate')
let res = timeagoInstance.format(date)
stop('formatTimeagoDate')
return res
}

View File

@ -0,0 +1,13 @@
import { mark, stop } from './marks'
let domParser = process.browser && new DOMParser()
export function htmlToPlainText (html) {
if (!html) {
return ''
}
mark('htmlToPlainText')
let res = domParser.parseFromString(html, 'text/html').documentElement.textContent
stop('htmlToPlainText')
return res
}

View File

@ -1,4 +1,4 @@
import { getNthStatus, getNthStatusSelector } from '../utils' import { getNthStatusSelector } from '../utils'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
@ -8,12 +8,10 @@ fixture`005-status-types.js`
test('shows followers-only vs regular in home timeline', async t => { test('shows followers-only vs regular in home timeline', async t => {
await loginAsFoobar(t) await loginAsFoobar(t)
await t await t
.expect(getNthStatus(1).getAttribute('aria-label')).eql('Status by admin')
.expect($(`${getNthStatusSelector(1)} .status-content`).innerText).contains('notification of unlisted message') .expect($(`${getNthStatusSelector(1)} .status-content`).innerText).contains('notification of unlisted message')
.expect($(`${getNthStatusSelector(1)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label')) .expect($(`${getNthStatusSelector(1)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Boost') .eql('Boost')
.expect($(`${getNthStatusSelector(1)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).notOk() .expect($(`${getNthStatusSelector(1)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).notOk()
.expect(getNthStatus(2).getAttribute('aria-label')).eql('Status by admin')
.expect($(`${getNthStatusSelector(2)} .status-content`).innerText).contains('notification of followers-only message') .expect($(`${getNthStatusSelector(2)} .status-content`).innerText).contains('notification of followers-only message')
.expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label')) .expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Cannot be boosted because this is followers-only') .eql('Cannot be boosted because this is followers-only')
@ -24,17 +22,14 @@ test('shows direct vs followers-only vs regular in notifications', async t => {
await loginAsFoobar(t) await loginAsFoobar(t)
await t await t
.navigateTo('/notifications') .navigateTo('/notifications')
.expect(getNthStatus(2).getAttribute('aria-label')).eql('Status by admin')
.expect($(`${getNthStatusSelector(2)} .status-content`).innerText).contains('notification of unlisted message') .expect($(`${getNthStatusSelector(2)} .status-content`).innerText).contains('notification of unlisted message')
.expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label')) .expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Boost') .eql('Boost')
.expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).notOk() .expect($(`${getNthStatusSelector(2)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).notOk()
.expect(getNthStatus(3).getAttribute('aria-label')).eql('Status by admin')
.expect($(`${getNthStatusSelector(3)} .status-content`).innerText).contains('notification of followers-only message') .expect($(`${getNthStatusSelector(3)} .status-content`).innerText).contains('notification of followers-only message')
.expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label')) .expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Cannot be boosted because this is followers-only') .eql('Cannot be boosted because this is followers-only')
.expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).ok() .expect($(`${getNthStatusSelector(3)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).ok()
.expect(getNthStatus(4).getAttribute('aria-label')).eql('Direct message by admin')
.expect($(`${getNthStatusSelector(4)} .status-content`).innerText).contains('notification of direct message') .expect($(`${getNthStatusSelector(4)} .status-content`).innerText).contains('notification of direct message')
.expect($(`${getNthStatusSelector(4)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label')) .expect($(`${getNthStatusSelector(4)} .status-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Cannot be boosted because this is a direct message') .eql('Cannot be boosted because this is a direct message')

View File

@ -0,0 +1,69 @@
import { loginAsFoobar } from '../roles'
import { getNthShowOrHideButton, getNthStatus, notificationsNavButton, scrollToStatus } from '../utils'
import { indexWhere } from '../../routes/_utils/arrays'
import { homeTimeline } from '../fixtures'
fixture`022-status-aria-label.js`
.page`http://localhost:4002`
test('basic aria-labels for statuses', async t => {
await loginAsFoobar(t)
await t
.hover(getNthStatus(0))
.expect(getNthStatus(0).getAttribute('aria-label')).match(
/quux, pinned toot 1, .+ ago, @quux, Unlisted, Boosted by admin/i
)
.hover(getNthStatus(0))
.expect(getNthStatus(1).getAttribute('aria-label')).match(
/admin, @foobar notification of unlisted message, .* ago, @admin, Unlisted/i
)
})
test('aria-labels for CWed statuses', async t => {
await loginAsFoobar(t)
let kittenIdx = indexWhere(homeTimeline, _ => _.spoiler === 'kitten CW')
await scrollToStatus(t, kittenIdx)
await t
.hover(getNthStatus(kittenIdx))
.expect(getNthStatus(kittenIdx).getAttribute('aria-label')).match(
/foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
)
.click(getNthShowOrHideButton(kittenIdx))
.expect(getNthStatus(kittenIdx).getAttribute('aria-label')).match(
/foobar, here's a kitten with a CW, .* ago, @foobar, Public/i
)
.click(getNthShowOrHideButton(kittenIdx))
.expect(getNthStatus(kittenIdx).getAttribute('aria-label')).match(
/foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
)
})
test('aria-labels for notifications', async t => {
await loginAsFoobar(t)
await t
.click(notificationsNavButton)
.hover(getNthStatus(0))
.expect(getNthStatus(0).getAttribute('aria-label')).match(
/admin favorited your status, foobar, this is unlisted, .* ago, @foobar, Unlisted/i
)
.hover(getNthStatus(1))
.expect(getNthStatus(1).getAttribute('aria-label')).match(
/admin boosted your status, foobar, this is unlisted, .* ago, @foobar, Unlisted/i
)
.hover(getNthStatus(2))
.expect(getNthStatus(2).getAttribute('aria-label')).match(
/admin, @foobar notification of unlisted message, .* ago, @admin, Unlisted/i
)
await scrollToStatus(t, 4)
await t
.hover(getNthStatus(4))
.expect(getNthStatus(4).getAttribute('aria-label')).match(
/admin, @foobar notification of direct message, .* ago, @admin, Direct/i
)
await scrollToStatus(t, 5)
await t
.hover(getNthStatus(5))
.expect(getNthStatus(5).getAttribute('aria-label')).match(
/quux followed you, @quux/i
)
})

View File

@ -1,12 +1,17 @@
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
import { import {
avatarInComposeBox, avatarInComposeBox,
displayNameInComposeBox, generalSettingsButton, getNthStatus, getNthStatusSelector, getUrl, homeNavButton, displayNameInComposeBox,
generalSettingsButton,
getNthStatus,
getNthStatusSelector,
getUrl,
homeNavButton,
removeEmojiFromDisplayNamesInput, removeEmojiFromDisplayNamesInput,
settingsNavButton, settingsNavButton,
sleep sleep
} from '../utils' } from '../utils'
import { updateUserDisplayNameAs } from '../serverActions' import { postAs, updateUserDisplayNameAs } from '../serverActions'
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
fixture`118-display-name-custom-emoji.js` fixture`118-display-name-custom-emoji.js`
@ -85,26 +90,34 @@ test('Cannot remove emoji from user display names if result would be empty', asy
}) })
test('Check status aria labels for de-emojified text', async t => { test('Check status aria labels for de-emojified text', async t => {
await updateUserDisplayNameAs('foobar', '🌈 foo :blobpats: 🌈') let rainbow = String.fromCodePoint(0x1F308)
await updateUserDisplayNameAs('foobar', `${rainbow} foo :blobpats: ${rainbow}`)
await postAs('foobar', 'hey ho lotsa emojos')
await sleep(1000) await sleep(1000)
await loginAsFoobar(t) await loginAsFoobar(t)
await t await t
.click(displayNameInComposeBox) .click(displayNameInComposeBox)
.expect(getNthStatus(0).getAttribute('aria-label')).eql('Status by 🌈 foo :blobpats: 🌈') .expect(getNthStatus(0).getAttribute('aria-label')).match(
new RegExp(`${rainbow} foo :blobpats: ${rainbow}, hey ho lotsa emojos, (.* ago|just now), @foobar, Public`, 'i')
)
.click(settingsNavButton) .click(settingsNavButton)
.click(generalSettingsButton) .click(generalSettingsButton)
.click(removeEmojiFromDisplayNamesInput) .click(removeEmojiFromDisplayNamesInput)
.expect(removeEmojiFromDisplayNamesInput.checked).ok() .expect(removeEmojiFromDisplayNamesInput.checked).ok()
.click(homeNavButton) .click(homeNavButton)
.click(displayNameInComposeBox) .click(displayNameInComposeBox)
.expect(getNthStatus(0).getAttribute('aria-label')).eql('Status by foo') .expect(getNthStatus(0).getAttribute('aria-label')).match(
new RegExp(`foo, hey ho lotsa emojos, (.* ago|just now), @foobar, Public`, 'i')
)
.click(settingsNavButton) .click(settingsNavButton)
.click(generalSettingsButton) .click(generalSettingsButton)
.click(removeEmojiFromDisplayNamesInput) .click(removeEmojiFromDisplayNamesInput)
.expect(removeEmojiFromDisplayNamesInput.checked).notOk() .expect(removeEmojiFromDisplayNamesInput.checked).notOk()
.click(homeNavButton) .click(homeNavButton)
.click(displayNameInComposeBox) .click(displayNameInComposeBox)
.expect(getNthStatus(0).getAttribute('aria-label')).eql('Status by 🌈 foo :blobpats: 🌈') .expect(getNthStatus(0).getAttribute('aria-label')).match(
new RegExp(`${rainbow} foo :blobpats: ${rainbow}, hey ho lotsa emojos, (.* ago|just now), @foobar, Public`, 'i')
)
}) })
test('Check some odd emoji', async t => { test('Check some odd emoji', async t => {