]> BookStack Code Mirror - bookstack/commitdiff
Page display pointer: Considerably improved accessibility
authorDan Brown <redacted>
Wed, 31 May 2023 15:38:20 +0000 (16:38 +0100)
committerDan Brown <redacted>
Wed, 31 May 2023 15:44:20 +0000 (16:44 +0100)
- 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
resources/js/components/page-display.js
resources/js/components/pointer.js
resources/js/services/dom.js
resources/sass/_buttons.scss
resources/sass/_layout.scss
resources/sass/_pages.scss
resources/views/pages/parts/pointer.blade.php
tests/Entity/PageTest.php

index 9614f92fe1ff8b800ad087fff13c74ada1aabcbc..501fc9f2a0e9231c5b7cd95cc9de59740819742d 100644 (file)
@@ -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',
index eb7df5fb6460b37b52835a719d78df7f11d28aaa..bd1986c6ca8a37ef2337d64b00ec1a967401b29f 100644 (file)
@@ -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);
     });
 }
index e2e2ceca729fd0d5952ece4c3b6eb09bd1657267..b16a50de643e12300997f87b18eca1e182705dd8 100644 (file)
@@ -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();
+        });
+    }
+
 }
index d764a2ebe511f19cfeb0f49da1edaf91b833315d..bcfd0b565da2ab9b9144af2243d2cdb2fe13cc50 100644 (file)
@@ -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);
 }
 
 /**
index 3c6775ad50afadb228a2b47540099831d5c19be0..7fa7a65b195847f04c0715c34e2ceac397208130 100644 (file)
@@ -106,7 +106,7 @@ button {
   display: block;
 }
 
-.button.icon, .icon-button {
+.button.icon, .icon-button, .text-button.icon {
   .svg-icon {
     margin-inline-end: 0;
   }
index 11889da172239b0ef346ab93d298729d86d36555..a8604b81b18240edf43f32a2f0965d61b571f3d9 100644 (file)
@@ -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
  */
index a88d58f99dcbbc6c0c6252e22cb6ab2a9e33aebc..2a77e84ba34253330831383d949c8c15d0a087e1 100755 (executable)
@@ -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;
index 5bafa6e153fffea70da598c60e53b212e382c064..56f36cb75f3d91b48d446a86ad8715f4f36a7bab 100644 (file)
@@ -1,16 +1,36 @@
+
 <div component="pointer"
-     option:pointer:page-id="{{ $page->id }}"
-     id="pointer"
-     class="pointer-container">
-    <div class="pointer anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
-        <span class="icon mr-xxs">@icon('link') @icon('include', ['style' => 'display:none;'])</span>
-        <div class="input-group inline block">
-            <input refs="pointer@input" readonly="readonly" type="text" id="pointer-url" placeholder="url">
-            <button refs="pointer@button" class="button outline icon" data-clipboard-target="#pointer-url" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+      option:pointer:page-id="{{ $page->id }}">
+    <div id="pointer"
+         refs="pointer@pointer"
+         tabindex="-1"
+         aria-label="{{ trans('entities.pages_pointer_label') }}"
+         class="pointer-container">
+        <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
+            <div refs="pointer@mode-section" class="flex-container-row items-center gap-s">
+                <button refs="pointer@mode-toggle"
+                        title="{{ trans('entities.pages_pointer_toggle_link') }}"
+                        class="text-button icon px-xs">@icon('link')</button>
+                <div class="input-group">
+                    <input refs="pointer@link-input" aria-label="{{ trans('entities.pages_pointer_permalink') }}" readonly="readonly" type="text" id="pointer-url" placeholder="url">
+                    <button refs="pointer@link-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                </div>
+            </div>
+            <div refs="pointer@mode-section" hidden class="flex-container-row items-center gap-s">
+                <button refs="pointer@mode-toggle"
+                        title="{{ trans('entities.pages_pointer_toggle_include') }}"
+                        class="text-button icon px-xs">@icon('include')</button>
+                <div class="input-group">
+                    <input refs="pointer@include-input" aria-label="{{ trans('entities.pages_pointer_include_tag') }}" readonly="readonly" type="text" id="pointer-include" placeholder="include">
+                    <button refs="pointer@include-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                </div>
+            </div>
+            @if(userCan('page-update', $page))
+                <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
+                   class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
+            @endif
         </div>
-        @if(userCan('page-update', $page))
-            <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
-               class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
-        @endif
     </div>
-</div>
\ No newline at end of file
+
+    <button refs="pointer@section-mode-button" class="screen-reader-only">{{ trans('entities.pages_pointer_enter_mode') }}</button>
+</div>
index 370c4381c63c8e22c9cc815aab6f2a10ea245c3e..daad82e76dc5cbf586bf80eb59290da31ef3436d 100644 (file)
@@ -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']);