From 88785aa71b1da3c33928c3bcd67e77b2572c5208 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 31 May 2023 16:38:20 +0100 Subject: [PATCH] Page display pointer: Considerably improved accessibility - Updated pointer to move within content DOM so that you can back-focus into the pointer if desired. - Added new "Section select mode" which toggles focusabiltiy for main content sections, with ability to show pointer via enter press on these. - Updated pointer with proper input/button labelling. Tested via orca screen reader on Firefox/Fedora/Gnome. For #3975 --- lang/en/entities.php | 8 +- resources/js/components/page-display.js | 2 +- resources/js/components/pointer.js | 103 +++++++++++------- resources/js/services/dom.js | 27 ++++- resources/sass/_buttons.scss | 2 +- resources/sass/_layout.scss | 9 ++ resources/sass/_pages.scss | 14 +-- resources/views/pages/parts/pointer.blade.php | 46 +++++--- tests/Entity/PageTest.php | 7 ++ 9 files changed, 147 insertions(+), 71 deletions(-) diff --git a/lang/en/entities.php b/lang/en/entities.php index 9614f92fe..501fc9f2a 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -266,7 +266,13 @@ return [ 'pages_revisions_restore' => 'Restore', 'pages_revisions_none' => 'This page has no revisions', 'pages_copy_link' => 'Copy Link', - 'pages_edit_content_link' => 'Edit Content', + 'pages_edit_content_link' => 'Jump to section in editor', + 'pages_pointer_enter_mode' => 'Enter section select mode', + 'pages_pointer_label' => 'Page Section Options', + 'pages_pointer_permalink' => 'Page Section Permalink', + 'pages_pointer_include_tag' => 'Page Section Include Tag', + 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', + 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', 'pages_permissions_active' => 'Page Permissions Active', 'pages_initial_revision' => 'Initial publish', 'pages_references_update_revision' => 'System auto-update of internal links', diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index eb7df5fb6..bd1986c6c 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -3,7 +3,7 @@ import {scrollAndHighlightElement} from '../services/util'; import {Component} from './component'; function toggleAnchorHighlighting(elementId, shouldHighlight) { - DOM.forEach(`a[href="#${elementId}"]`, anchor => { + DOM.forEach(`#page-navigation a[href="#${elementId}"]`, anchor => { anchor.closest('li').classList.toggle('current-heading', shouldHighlight); }); } diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.js index e2e2ceca7..b16a50de6 100644 --- a/resources/js/components/pointer.js +++ b/resources/js/components/pointer.js @@ -6,64 +6,74 @@ export class Pointer extends Component { setup() { this.container = this.$el; - this.input = this.$refs.input; - this.button = this.$refs.button; + this.pointer = this.$refs.pointer; + this.linkInput = this.$refs.linkInput; + this.linkButton = this.$refs.linkButton; + this.includeInput = this.$refs.includeInput; + this.includeButton = this.$refs.includeButton; + this.sectionModeButton = this.$refs.sectionModeButton; + this.modeToggles = this.$manyRefs.modeToggle; + this.modeSections = this.$manyRefs.modeSection; this.pageId = this.$opts.pageId; // Instance variables this.showing = false; this.isSelection = false; - this.pointerModeLink = true; - this.pointerSectionId = ''; this.setupListeners(); } setupListeners() { // Copy on copy button click - this.button.addEventListener('click', () => { - copyTextToClipboard(this.input.value); - }); + this.includeButton.addEventListener('click', () => copyTextToClipboard(this.includeInput.value)); + this.linkButton.addEventListener('click', () => copyTextToClipboard(this.linkInput.value)); // Select all contents on input click - this.input.addEventListener('click', event => { - this.input.select(); + DOM.onSelect([this.includeInput, this.linkInput], event => { + event.target.select(); event.stopPropagation(); }); // Prevent closing pointer when clicked or focused - DOM.onEvents(this.container, ['click', 'focus'], event => { + DOM.onEvents(this.pointer, ['click', 'focus'], event => { event.stopPropagation(); }); - // Pointer mode toggle - DOM.onChildEvent(this.container, 'span.icon', 'click', (event, icon) => { - event.stopPropagation(); - this.pointerModeLink = !this.pointerModeLink; - icon.querySelector('[data-icon="include"]').style.display = (!this.pointerModeLink) ? 'inline' : 'none'; - icon.querySelector('[data-icon="link"]').style.display = (this.pointerModeLink) ? 'inline' : 'none'; - this.updateForTarget(); - }); - // Hide pointer when clicking away DOM.onEvents(document.body, ['click', 'focus'], () => { if (!this.showing || this.isSelection) return; this.hidePointer(); }); + // Hide pointer on escape press + DOM.onEscapePress(this.pointer, this.hidePointer.bind(this)); + // Show pointer when selecting a single block of tagged content const pageContent = document.querySelector('.page-content'); DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => { event.stopPropagation(); const targetEl = event.target.closest('[id^="bkmrk"]'); - if (targetEl) { - this.showPointerAtTarget(targetEl, event.pageX); + if (targetEl && window.getSelection().toString().length > 0) { + this.showPointerAtTarget(targetEl, event.pageX, false); } }); + + // Start section selection mode on button press + DOM.onSelect(this.sectionModeButton, this.enterSectionSelectMode.bind(this)); + + // Toggle between pointer modes + DOM.onSelect(this.modeToggles, event => { + for (const section of this.modeSections) { + const show = !section.contains(event.target); + section.toggleAttribute('hidden', !show); + } + + this.modeToggles.find(b => b !== event.target).focus(); + }); } hidePointer() { - this.container.style.display = null; + this.pointer.style.display = null; this.showing = false; } @@ -71,25 +81,21 @@ export class Pointer extends Component { * Move and display the pointer at the given element, targeting the given screen x-position if possible. * @param {Element} element * @param {Number} xPosition + * @param {Boolean} keyboardMode */ - showPointerAtTarget(element, xPosition) { - const selection = window.getSelection(); - if (selection.toString().length === 0) return; - - // Show pointer and set link - this.pointerSectionId = element.id; + showPointerAtTarget(element, xPosition, keyboardMode) { this.updateForTarget(element); - this.container.style.display = 'block'; + this.pointer.style.display = 'block'; const targetBounds = element.getBoundingClientRect(); - const pointerBounds = this.container.getBoundingClientRect(); + const pointerBounds = this.pointer.getBoundingClientRect(); const xTarget = Math.min(Math.max(xPosition, targetBounds.left), targetBounds.right); const xOffset = xTarget - (pointerBounds.width / 2); const yOffset = (targetBounds.top - pointerBounds.height) - 16; - this.container.style.left = `${xOffset}px`; - this.container.style.top = `${yOffset}px`; + this.pointer.style.left = `${xOffset}px`; + this.pointer.style.top = `${yOffset}px`; this.showing = true; this.isSelection = true; @@ -102,7 +108,11 @@ export class Pointer extends Component { this.hidePointer(); window.removeEventListener('scroll', scrollListener, {passive: true}); }; - window.addEventListener('scroll', scrollListener, {passive: true}); + + element.parentElement.insertBefore(this.pointer, element); + if (!keyboardMode) { + window.addEventListener('scroll', scrollListener, {passive: true}); + } } /** @@ -110,23 +120,36 @@ export class Pointer extends Component { * @param {?Element} element */ updateForTarget(element) { - let inputText = this.pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${this.pointerSectionId}`) : `{{@${this.pageId}#${this.pointerSectionId}}}`; - if (this.pointerModeLink && !inputText.startsWith('http')) { - inputText = `${window.location.protocol}//${window.location.host}${inputText}`; - } + const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`); + const includeTag = `{{@${this.pageId}#${element.id}}}`; - this.input.value = inputText; + this.linkInput.value = permaLink; + this.includeInput.value = includeTag; // Update anchor if present - const editAnchor = this.container.querySelector('#pointer-edit'); + const editAnchor = this.pointer.querySelector('#pointer-edit'); if (editAnchor && element) { const {editHref} = editAnchor.dataset; const elementId = element.id; - // get the first 50 characters. + // Get the first 50 characters. const queryContent = element.textContent && element.textContent.substring(0, 50); editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`; } } + enterSectionSelectMode() { + const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')); + for (const section of sections) { + section.setAttribute('tabindex', '0'); + } + + sections[0].focus(); + + DOM.onEnterPress(sections, event => { + this.showPointerAtTarget(event.target, 0, true); + this.pointer.focus(); + }); + } + } diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js index d764a2ebe..bcfd0b565 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.js @@ -75,22 +75,41 @@ export function onSelect(elements, callback) { } /** - * Listen to enter press on the given element(s). + * Listen to key press on the given element(s). + * @param {String} key * @param {HTMLElement|Array} elements * @param {function} callback */ -export function onEnterPress(elements, callback) { +function onKeyPress(key, elements, callback) { if (!Array.isArray(elements)) { elements = [elements]; } const listener = event => { - if (event.key === 'Enter') { + if (event.key === key) { callback(event); } }; - elements.forEach(e => e.addEventListener('keypress', listener)); + elements.forEach(e => e.addEventListener('keydown', listener)); +} + +/** + * Listen to enter press on the given element(s). + * @param {HTMLElement|Array} elements + * @param {function} callback + */ +export function onEnterPress(elements, callback) { + onKeyPress('Enter', elements, callback); +} + +/** + * Listen to escape press on the given element(s). + * @param {HTMLElement|Array} elements + * @param {function} callback + */ +export function onEscapePress(elements, callback) { + onKeyPress('Escape', elements, callback); } /** diff --git a/resources/sass/_buttons.scss b/resources/sass/_buttons.scss index 3c6775ad5..7fa7a65b1 100644 --- a/resources/sass/_buttons.scss +++ b/resources/sass/_buttons.scss @@ -106,7 +106,7 @@ button { display: block; } -.button.icon, .icon-button { +.button.icon, .icon-button, .text-button.icon { .svg-icon { margin-inline-end: 0; } diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 11889da17..a8604b81b 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -302,6 +302,15 @@ body.flexbox { display: none !important; } +.screen-reader-only { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + /** * Border radiuses */ diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index a88d58f99..2a77e84ba 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -198,10 +198,6 @@ body.tox-fullscreen, body.markdown-fullscreen { .pointer { border: 1px solid #CCC; @include lightDark(border-color, #ccc, #000); - display: flex; - align-items: center; - justify-items: center; - padding: $-s $-s; border-radius: 4px; box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1); @include lightDark(background-color, #fff, #333); @@ -241,16 +237,12 @@ body.tox-fullscreen, body.markdown-fullscreen { border: 1px solid #DDD; @include lightDark(border-color, #ddd, #000); color: #666; - width: 172px; + width: 160px; z-index: 40; padding: 5px 10px; } - span.icon { - fill: #444; - cursor: pointer; - user-select: none; - display: inline-block; - line-height: 1; + .text-button { + @include lightDark(color, #444, #AAA); } .input-group .button { line-height: 1; diff --git a/resources/views/pages/parts/pointer.blade.php b/resources/views/pages/parts/pointer.blade.php index 5bafa6e15..56f36cb75 100644 --- a/resources/views/pages/parts/pointer.blade.php +++ b/resources/views/pages/parts/pointer.blade.php @@ -1,16 +1,36 @@ +
-
- @icon('link') @icon('include', ['style' => 'display:none;']) -
- - + option:pointer:page-id="{{ $page->id }}"> +
+
+
+ +
+ + +
+
+ + @if(userCan('page-update', $page)) + @icon('edit') + @endif
- @if(userCan('page-update', $page)) - @icon('edit') - @endif
-
\ No newline at end of file + + +
diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index 370c4381c..daad82e76 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -50,6 +50,13 @@ class PageTest extends TestCase $resp->assertSeeText('Owned by ' . $owner->name); } + public function test_page_show_includes_pointer_section_select_mode_button() + { + $page = $this->entities->page(); + $resp = $this->asEditor()->get($page->getUrl()); + $this->withHtml($resp)->assertElementContains('.content-wrap button.screen-reader-only', 'Enter section select mode'); + } + public function test_page_creation_with_markdown_content() { $this->setSettings(['app-editor' => 'markdown']);