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

View File

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

View File

@ -2,9 +2,9 @@
href="/statuses/{originalStatusId}"
focus-key={focusKey}
>
<time datetime={createdAtDate} title={relativeDate}
aria-label="{relativeDate} click to show thread">
{relativeDate}
<time datetime={createdAtDate} title={timeagoFormattedDate}
aria-label="{timeagoFormattedDate} click to show thread">
{timeagoFormattedDate}
</time>
</a>
<style>
@ -29,19 +29,8 @@
</style>
<script>
import { mark, stop } from '../../_utils/marks'
import timeago from 'timeago.js'
const timeagoInstance = timeago()
export default {
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}`
}
}

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 { Selector as $ } from 'testcafe'
@ -8,12 +8,10 @@ fixture`005-status-types.js`
test('shows followers-only vs regular in home timeline', async t => {
await loginAsFoobar(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-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Boost')
.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-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.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 t
.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-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Boost')
.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-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.eql('Cannot be boosted because this is followers-only')
.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-toolbar button:nth-child(2)`).getAttribute('aria-label'))
.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 {
avatarInComposeBox,
displayNameInComposeBox, generalSettingsButton, getNthStatus, getNthStatusSelector, getUrl, homeNavButton,
displayNameInComposeBox,
generalSettingsButton,
getNthStatus,
getNthStatusSelector,
getUrl,
homeNavButton,
removeEmojiFromDisplayNamesInput,
settingsNavButton,
sleep
} from '../utils'
import { updateUserDisplayNameAs } from '../serverActions'
import { postAs, updateUserDisplayNameAs } from '../serverActions'
import { Selector as $ } from 'testcafe'
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 => {
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 loginAsFoobar(t)
await t
.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(generalSettingsButton)
.click(removeEmojiFromDisplayNamesInput)
.expect(removeEmojiFromDisplayNamesInput.checked).ok()
.click(homeNavButton)
.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(generalSettingsButton)
.click(removeEmojiFromDisplayNamesInput)
.expect(removeEmojiFromDisplayNamesInput.checked).notOk()
.click(homeNavButton)
.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 => {