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 8bc2ee364..b94b12290 100644 --- a/framework/core/js/src/common/components/Dropdown.tsx +++ b/framework/core/js/src/common/components/Dropdown.tsx @@ -124,12 +124,13 @@ export default class Dropdown { - if (e.relatedTarget && !this.$().has(e.relatedTarget).length) { + this.$().on('focusout', (e: JQuery.FocusOutEvent) => { + // 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/utils/a11y.ts b/framework/core/js/src/common/utils/a11y.ts new file mode 100644 index 000000000..061cca868 --- /dev/null +++ b/framework/core/js/src/common/utils/a11y.ts @@ -0,0 +1,22 @@ +/** + * 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(); + console.log('intercepted'); + 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/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 649bcfacf..a8b026423 100644 --- a/framework/core/js/src/forum/components/PageStructure.tsx +++ b/framework/core/js/src/forum/components/PageStructure.tsx @@ -5,6 +5,7 @@ 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; @@ -52,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 { @@ -73,7 +78,7 @@ export default class PageStructure + prepareSkipLinks()}> {app.translator.trans('core.forum.index.skip_to_main_content')} , 200 diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index def8b54f6..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."