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:
parent
2db06ea472
commit
cc81a7bec6
|
@ -143,7 +143,8 @@
|
|||
"Element",
|
||||
"Image",
|
||||
"NotificationEvent",
|
||||
"NodeList"
|
||||
"NodeList",
|
||||
"DOMParser"
|
||||
],
|
||||
"ignore": [
|
||||
"dist",
|
||||
|
|
49
routes/_a11y/getAccessibleLabelForStatus.js
Normal file
49
routes/_a11y/getAccessibleLabelForStatus.js
Normal 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(', ')
|
||||
}
|
10
routes/_a11y/getAccountAccessibleName.js
Normal file
10
routes/_a11y/getAccountAccessibleName.js
Normal 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
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`
|
||||
}
|
||||
}
|
||||
|
|
11
routes/_intl/formatTimeagoDate.js
Normal file
11
routes/_intl/formatTimeagoDate.js
Normal 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
|
||||
}
|
13
routes/_utils/htmlToPlainText.js
Normal file
13
routes/_utils/htmlToPlainText.js
Normal 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
|
||||
}
|
|
@ -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')
|
||||
|
|
69
tests/spec/022-status-aria-label.js
Normal file
69
tests/spec/022-status-aria-label.js
Normal 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
|
||||
)
|
||||
})
|
|
@ -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 => {
|
||||
|
|
Loading…
Reference in a new issue