From 649be7cb031a55aa7ea13d2b4f1ae09c5e828667 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 25 Apr 2025 09:17:36 +0100 Subject: [PATCH] chore(a11y): misc a11y improvements (#4211) --- extensions/approval/js/src/forum/index.js | 7 +++++- extensions/lock/js/src/forum/addLockBadge.js | 5 ++++- .../sticky/js/src/forum/addStickyBadge.js | 2 +- .../js/src/forum/addSubscriptionBadge.js | 18 +++++++++++++-- extensions/suspend/js/src/forum/index.js | 2 +- framework/core/js/src/common/Application.tsx | 3 +++ .../js/src/common/components/Dropdown.tsx | 8 +++++++ .../core/js/src/common/models/Discussion.tsx | 2 +- framework/core/js/src/common/utils/a11y.ts | 21 ++++++++++++++++++ .../js/src/forum/components/AvatarEditor.js | 5 +++-- .../forum/components/DiscussionListItem.tsx | 10 ++++----- .../forum/components/DiscussionListPane.js | 17 ++++++++++++-- .../js/src/forum/components/PageStructure.tsx | 22 +++++++++++++++++-- framework/core/less/common/Avatar.less | 4 ---- framework/core/less/common/scaffolding.less | 10 +++++++++ framework/core/less/common/variables.less | 4 ++++ framework/core/less/forum/AvatarEditor.less | 3 +++ .../core/less/forum/DiscussionListItem.less | 8 ++++++- framework/core/locale/core.yml | 6 +++++ framework/core/views/frontend/admin.blade.php | 1 + framework/core/views/frontend/forum.blade.php | 1 + 21 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 framework/core/js/src/common/utils/a11y.ts diff --git a/extensions/approval/js/src/forum/index.js b/extensions/approval/js/src/forum/index.js index 5f76395eb..cd6e40c6b 100644 --- a/extensions/approval/js/src/forum/index.js +++ b/extensions/approval/js/src/forum/index.js @@ -18,7 +18,12 @@ app.initializers.add( if (!this.isApproved() && !items.has('hidden')) { items.add( 'awaitingApproval', - + ); } }); diff --git a/extensions/lock/js/src/forum/addLockBadge.js b/extensions/lock/js/src/forum/addLockBadge.js index 6437caa52..364b78b36 100644 --- a/extensions/lock/js/src/forum/addLockBadge.js +++ b/extensions/lock/js/src/forum/addLockBadge.js @@ -6,7 +6,10 @@ import Badge from 'flarum/common/components/Badge'; export default function addLockBadge() { extend(Discussion.prototype, 'badges', function (badges) { if (this.isLocked()) { - badges.add('locked', ); + badges.add( + 'locked', + + ); } }); } diff --git a/extensions/sticky/js/src/forum/addStickyBadge.js b/extensions/sticky/js/src/forum/addStickyBadge.js index e99f80380..c92a35f70 100644 --- a/extensions/sticky/js/src/forum/addStickyBadge.js +++ b/extensions/sticky/js/src/forum/addStickyBadge.js @@ -8,7 +8,7 @@ export default function addStickyBadge() { if (this.isSticky()) { badges.add( 'sticky', - , + , 10 ); } diff --git a/extensions/subscriptions/js/src/forum/addSubscriptionBadge.js b/extensions/subscriptions/js/src/forum/addSubscriptionBadge.js index f40028c24..432da6903 100644 --- a/extensions/subscriptions/js/src/forum/addSubscriptionBadge.js +++ b/extensions/subscriptions/js/src/forum/addSubscriptionBadge.js @@ -9,11 +9,25 @@ export default function addSubscriptionBadge() { switch (this.subscription()) { case 'follow': - badge = ; + badge = ( + + ); break; case 'ignore': - badge = ; + badge = ( + + ); break; } diff --git a/extensions/suspend/js/src/forum/index.js b/extensions/suspend/js/src/forum/index.js index 5a4a01ecf..80eedc15e 100644 --- a/extensions/suspend/js/src/forum/index.js +++ b/extensions/suspend/js/src/forum/index.js @@ -28,7 +28,7 @@ app.initializers.add('flarum-suspend', () => { if (new Date() < until) { items.add( 'suspended', - , + , 100 ); } diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index 1c49f71f5..a96957b1a 100644 --- a/framework/core/js/src/common/Application.tsx +++ b/framework/core/js/src/common/Application.tsx @@ -39,6 +39,7 @@ import IExtender from './extenders/IExtender'; import AccessToken from './models/AccessToken'; import SearchManager from './SearchManager'; import { ColorScheme } from './components/ThemeMode'; +import { prepareSkipLinks } from './utils/a11y'; export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd'; @@ -394,6 +395,8 @@ export default class Application { this.initColorScheme(); liveHumanTimes(); + + prepareSkipLinks(); } private initColorScheme(forumDefault: string | null = null): void { diff --git a/framework/core/js/src/common/components/Dropdown.tsx b/framework/core/js/src/common/components/Dropdown.tsx index 3e5d43fc8..b94b12290 100644 --- a/framework/core/js/src/common/components/Dropdown.tsx +++ b/framework/core/js/src/common/components/Dropdown.tsx @@ -123,6 +123,14 @@ export default class Dropdown { + // Check if the new focused element is outside of this dropdown + if (!this.$().has(e.relatedTarget as Element).length) { + this.$().trigger('hidden.bs.dropdown'); + } + }); + // Focusing out of the dropdown should close it. } /** diff --git a/framework/core/js/src/common/models/Discussion.tsx b/framework/core/js/src/common/models/Discussion.tsx index 15d8febbd..782a97973 100644 --- a/framework/core/js/src/common/models/Discussion.tsx +++ b/framework/core/js/src/common/models/Discussion.tsx @@ -131,7 +131,7 @@ export default class Discussion extends Model { const items = new ItemList(); if (this.isHidden()) { - items.add('hidden', ); + items.add('hidden', ); } return items; diff --git a/framework/core/js/src/common/utils/a11y.ts b/framework/core/js/src/common/utils/a11y.ts new file mode 100644 index 000000000..bdc742b57 --- /dev/null +++ b/framework/core/js/src/common/utils/a11y.ts @@ -0,0 +1,21 @@ +/** + * Fix a11y skip links by manually focusing on the href target element. + * This prevents unwanted/unexpected reloads of the page. + */ +export function prepareSkipLinks() { + document.querySelectorAll('.sr-only-focusable-custom:not([data-prepared])').forEach((el) => { + el.addEventListener('click', function (e) { + e.preventDefault(); + const target = el.getAttribute('href')!; + const $target = document.querySelector(target) as HTMLElement; + + if ($target) { + $target.setAttribute('tabindex', '-1'); + $target.focus(); + $target.removeAttribute('tabindex'); + + $target.dataset.prepared = 'true'; + } + }); + }); +} diff --git a/framework/core/js/src/forum/components/AvatarEditor.js b/framework/core/js/src/forum/components/AvatarEditor.js index fd1da1422..ff727e155 100644 --- a/framework/core/js/src/forum/components/AvatarEditor.js +++ b/framework/core/js/src/forum/components/AvatarEditor.js @@ -42,9 +42,10 @@ export default class AvatarEditor extends Component { return (
- )} - +
    {listItems(this.controlItems().toArray())}
); diff --git a/framework/core/js/src/forum/components/DiscussionListItem.tsx b/framework/core/js/src/forum/components/DiscussionListItem.tsx index d0c04855b..b5d4825c2 100644 --- a/framework/core/js/src/forum/components/DiscussionListItem.tsx +++ b/framework/core/js/src/forum/components/DiscussionListItem.tsx @@ -76,15 +76,15 @@ export default class DiscussionListItem { const items = new ItemList(); + items.add('slidableUnderneath', this.slidableUnderneathView(), 90); + items.add('content', this.contentView(), 80); + const controls = DiscussionControls.controls(this.attrs.discussion, this).toArray(); if (controls.length) { - items.add('controls', this.controlsView(controls), 100); + items.add('controls', this.controlsView(controls), 70); } - items.add('slidableUnderneath', this.slidableUnderneathView(), 90); - items.add('content', this.contentView(), 80); - return items; } @@ -312,7 +312,7 @@ export default class DiscussionListItem, ] : } label={showUnread ? abbreviateNumber(discussion.unreadCount()) : abbreviateNumber(discussion.replyCount())} - a11yLabel={app.translator.trans(a11yKey, { count: discussion.replyCount() })} + ariaLabel={app.translator.trans(a11yKey, { count: discussion.unreadCount() })} onclick={showUnread ? this.markAsRead.bind(this) : undefined} /> ); diff --git a/framework/core/js/src/forum/components/DiscussionListPane.js b/framework/core/js/src/forum/components/DiscussionListPane.js index a20bf9377..4c6680d86 100644 --- a/framework/core/js/src/forum/components/DiscussionListPane.js +++ b/framework/core/js/src/forum/components/DiscussionListPane.js @@ -2,6 +2,7 @@ import app from '../../forum/app'; import DiscussionList from './DiscussionList'; import Component from '../../common/Component'; import DiscussionPage from './DiscussionPage'; +import { prepareSkipLinks } from '../../common/utils/a11y'; const hotEdge = (e) => { if (e.pageX < 10) app.pane.show(); @@ -22,7 +23,14 @@ export default class DiscussionListPane extends Component { return; } - return ; + return ( + + ); } oncreate(vnode) { @@ -34,7 +42,12 @@ export default class DiscussionListPane extends Component { // and hide the pane respectively. We also create a 10px 'hot edge' on the // left of the screen to activate the pane. const pane = app.pane; - $list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane)); + $list.on('mouseenter', pane.show.bind(pane)); + $list.on('mouseleave', pane.onmouseleave.bind(pane)); + // a11y: when tabbing into the pane (focus) we should also show the pane. + // and when tabbing out, we should hide the pane. + $list.on('focus', 'a, .Button', pane.show.bind(pane)); + $list.on('blur', 'a, .Button', pane.onmouseleave.bind(pane)); $(document).on('mousemove', hotEdge); diff --git a/framework/core/js/src/forum/components/PageStructure.tsx b/framework/core/js/src/forum/components/PageStructure.tsx index c9bf025d0..a8b026423 100644 --- a/framework/core/js/src/forum/components/PageStructure.tsx +++ b/framework/core/js/src/forum/components/PageStructure.tsx @@ -1,9 +1,11 @@ +import app from '../app'; import Component from '../../common/Component'; import type { ComponentAttrs } from '../../common/Component'; import type Mithril from 'mithril'; import classList from '../../common/utils/classList'; import ItemList from '../../common/utils/ItemList'; import LoadingIndicator from '../../common/components/LoadingIndicator'; +import { prepareSkipLinks } from '../../common/utils/a11y'; export interface PageStructureAttrs extends ComponentAttrs { hero?: () => Mithril.Children; @@ -51,7 +53,11 @@ export default class PageStructure{this.attrs.loading ? this.loadingItems().toArray() : this.mainItems().toArray()}; + return ( +
+ {this.attrs.loading ? this.loadingItems().toArray() : this.mainItems().toArray()} +
+ ); } containerItems(): ItemList { @@ -70,6 +76,14 @@ export default class PageStructure { const items = new ItemList(); + items.add( + 'skipToMainContent', + prepareSkipLinks()}> + {app.translator.trans('core.forum.index.skip_to_main_content')} + , + 200 + ); + items.add('sidebar', (this.attrs.sidebar && this.attrs.sidebar()) || null, 100); return items; @@ -88,6 +102,10 @@ export default class PageStructure{this.content}; + return ( +
+ {this.content} +
+ ); } } diff --git a/framework/core/less/common/Avatar.less b/framework/core/less/common/Avatar.less index 644fb735b..520c641f2 100644 --- a/framework/core/less/common/Avatar.less +++ b/framework/core/less/common/Avatar.less @@ -20,10 +20,6 @@ border-radius: 100%; vertical-align: top; } - - span& { - cursor: default; - } } .Avatar--size(@size) { diff --git a/framework/core/less/common/scaffolding.less b/framework/core/less/common/scaffolding.less index bfa02fa37..ee1bcb975 100644 --- a/framework/core/less/common/scaffolding.less +++ b/framework/core/less/common/scaffolding.less @@ -199,3 +199,13 @@ blockquote ol:last-child { .text-colored { color: var(--color); } + +.sr-only-focusable-custom:focus { + clip: unset; + width: auto; + height: auto; + border-width: medium; + background: #000; + color: #fff; + z-index: 100; +} diff --git a/framework/core/less/common/variables.less b/framework/core/less/common/variables.less index 259b5ba35..63f8a6bee 100644 --- a/framework/core/less/common/variables.less +++ b/framework/core/less/common/variables.less @@ -136,6 +136,9 @@ @screen-phone-max: (@screen-tablet - 0.02); +@screen-small-phone: 320px; +@screen-small-phone-max: (@screen-small-phone + 0.02); + @screen-tablet: 768px; @screen-tablet-max: (@screen-desktop - 0.02); @@ -148,6 +151,7 @@ @screen-desktop-xxxl: 3000px; @phone: ~"(max-width: @{screen-phone-max})"; +@small-phone: ~"(max-width: @{screen-small-phone-max})"; @tablet: ~"(min-width: @{screen-tablet}) and (max-width: @{screen-tablet-max})"; @desktop: ~"(min-width: @{screen-desktop}) and (max-width: @{screen-desktop-max})"; @desktop-hd: ~"(min-width: @{screen-desktop-hd})"; diff --git a/framework/core/less/forum/AvatarEditor.less b/framework/core/less/forum/AvatarEditor.less index af78b1a3a..f038bd70e 100644 --- a/framework/core/less/forum/AvatarEditor.less +++ b/framework/core/less/forum/AvatarEditor.less @@ -15,6 +15,8 @@ inset: 0; border-radius: 100%; background: rgba(0, 0, 0, 0.6); + color: #fff; + cursor: pointer; text-align: center; text-decoration: none; border: 0; @@ -23,6 +25,7 @@ opacity: 0.7; } &:hover .Dropdown-toggle, + .Dropdown-toggle:focus, &.open .Dropdown-toggle, &.loading .Dropdown-toggle, &.dragover .Dropdown-toggle { diff --git a/framework/core/less/forum/DiscussionListItem.less b/framework/core/less/forum/DiscussionListItem.less index 599b752ef..a2eddee79 100644 --- a/framework/core/less/forum/DiscussionListItem.less +++ b/framework/core/less/forum/DiscussionListItem.less @@ -306,7 +306,7 @@ display: none; } - &:hover { + &:hover, &:focus { ._checkmark { display: block; } @@ -317,3 +317,9 @@ } } } + +@media @small-phone { + .DiscussionListItem-info { + max-width: 160px; + } +} diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 856dedd76..28d7da350 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -459,6 +459,7 @@ core: empty_text: It looks as though there are no discussions here. load_more_button: => core.ref.load_more replied_text: "{username} replied {ago}" + skip_discussion_list_pane: Skip the discussion list pane started_text: "{username} started {ago}" total_replies_a11y_label: "{count, plural, one {# reply} other {# replies}}" unread_replies_a11y_label: "{count, plural, one {# unread reply} other {# unread replies}}. Mark unread {count, plural, one {reply} other {replies}} as read." @@ -496,6 +497,7 @@ core: meta_title_text: => core.ref.all_discussions refresh_tooltip: => core.ref.refresh start_discussion_button: => core.ref.start_a_discussion + skip_to_main_content: Skip to main content toggle_sidenav_dropdown_accessible_label: Toggle navigation dropdown menu # These translations are used by the sorting control above the discussion list. @@ -911,6 +913,10 @@ core: next_page_button: => core.ref.next_page previous_page_button: => core.ref.previous_page + # Translations in this namespace are displayed by the basic HTML forum layout. + layout: + skip_to_content: Skip to content + # Translations in this namespace are displayed by the Log Out confirmation interface. log_out: log_out_button: => core.ref.log_out diff --git a/framework/core/views/frontend/admin.blade.php b/framework/core/views/frontend/admin.blade.php index d4bd21084..0ffcb045d 100644 --- a/framework/core/views/frontend/admin.blade.php +++ b/framework/core/views/frontend/admin.blade.php @@ -5,6 +5,7 @@