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
This commit is contained in:
Dan Brown
2023-05-31 16:38:20 +01:00
parent 0323ebccd3
commit 88785aa71b
9 changed files with 147 additions and 71 deletions

View File

@ -266,7 +266,13 @@ return [
'pages_revisions_restore' => 'Restore', 'pages_revisions_restore' => 'Restore',
'pages_revisions_none' => 'This page has no revisions', 'pages_revisions_none' => 'This page has no revisions',
'pages_copy_link' => 'Copy Link', '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_permissions_active' => 'Page Permissions Active',
'pages_initial_revision' => 'Initial publish', 'pages_initial_revision' => 'Initial publish',
'pages_references_update_revision' => 'System auto-update of internal links', 'pages_references_update_revision' => 'System auto-update of internal links',

View File

@ -3,7 +3,7 @@ import {scrollAndHighlightElement} from '../services/util';
import {Component} from './component'; import {Component} from './component';
function toggleAnchorHighlighting(elementId, shouldHighlight) { 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); anchor.closest('li').classList.toggle('current-heading', shouldHighlight);
}); });
} }

View File

@ -6,64 +6,74 @@ export class Pointer extends Component {
setup() { setup() {
this.container = this.$el; this.container = this.$el;
this.input = this.$refs.input; this.pointer = this.$refs.pointer;
this.button = this.$refs.button; 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; this.pageId = this.$opts.pageId;
// Instance variables // Instance variables
this.showing = false; this.showing = false;
this.isSelection = false; this.isSelection = false;
this.pointerModeLink = true;
this.pointerSectionId = '';
this.setupListeners(); this.setupListeners();
} }
setupListeners() { setupListeners() {
// Copy on copy button click // Copy on copy button click
this.button.addEventListener('click', () => { this.includeButton.addEventListener('click', () => copyTextToClipboard(this.includeInput.value));
copyTextToClipboard(this.input.value); this.linkButton.addEventListener('click', () => copyTextToClipboard(this.linkInput.value));
});
// Select all contents on input click // Select all contents on input click
this.input.addEventListener('click', event => { DOM.onSelect([this.includeInput, this.linkInput], event => {
this.input.select(); event.target.select();
event.stopPropagation(); event.stopPropagation();
}); });
// Prevent closing pointer when clicked or focused // Prevent closing pointer when clicked or focused
DOM.onEvents(this.container, ['click', 'focus'], event => { DOM.onEvents(this.pointer, ['click', 'focus'], event => {
event.stopPropagation(); 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 // Hide pointer when clicking away
DOM.onEvents(document.body, ['click', 'focus'], () => { DOM.onEvents(document.body, ['click', 'focus'], () => {
if (!this.showing || this.isSelection) return; if (!this.showing || this.isSelection) return;
this.hidePointer(); 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 // Show pointer when selecting a single block of tagged content
const pageContent = document.querySelector('.page-content'); const pageContent = document.querySelector('.page-content');
DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => { DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
event.stopPropagation(); event.stopPropagation();
const targetEl = event.target.closest('[id^="bkmrk"]'); const targetEl = event.target.closest('[id^="bkmrk"]');
if (targetEl) { if (targetEl && window.getSelection().toString().length > 0) {
this.showPointerAtTarget(targetEl, event.pageX); 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() { hidePointer() {
this.container.style.display = null; this.pointer.style.display = null;
this.showing = false; 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. * Move and display the pointer at the given element, targeting the given screen x-position if possible.
* @param {Element} element * @param {Element} element
* @param {Number} xPosition * @param {Number} xPosition
* @param {Boolean} keyboardMode
*/ */
showPointerAtTarget(element, xPosition) { showPointerAtTarget(element, xPosition, keyboardMode) {
const selection = window.getSelection();
if (selection.toString().length === 0) return;
// Show pointer and set link
this.pointerSectionId = element.id;
this.updateForTarget(element); this.updateForTarget(element);
this.container.style.display = 'block'; this.pointer.style.display = 'block';
const targetBounds = element.getBoundingClientRect(); 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 xTarget = Math.min(Math.max(xPosition, targetBounds.left), targetBounds.right);
const xOffset = xTarget - (pointerBounds.width / 2); const xOffset = xTarget - (pointerBounds.width / 2);
const yOffset = (targetBounds.top - pointerBounds.height) - 16; const yOffset = (targetBounds.top - pointerBounds.height) - 16;
this.container.style.left = `${xOffset}px`; this.pointer.style.left = `${xOffset}px`;
this.container.style.top = `${yOffset}px`; this.pointer.style.top = `${yOffset}px`;
this.showing = true; this.showing = true;
this.isSelection = true; this.isSelection = true;
@ -102,7 +108,11 @@ export class Pointer extends Component {
this.hidePointer(); this.hidePointer();
window.removeEventListener('scroll', scrollListener, {passive: true}); 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 * @param {?Element} element
*/ */
updateForTarget(element) { updateForTarget(element) {
let inputText = this.pointerModeLink ? window.baseUrl(`/link/${this.pageId}#${this.pointerSectionId}`) : `{{@${this.pageId}#${this.pointerSectionId}}}`; const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
if (this.pointerModeLink && !inputText.startsWith('http')) { const includeTag = `{{@${this.pageId}#${element.id}}}`;
inputText = `${window.location.protocol}//${window.location.host}${inputText}`;
}
this.input.value = inputText; this.linkInput.value = permaLink;
this.includeInput.value = includeTag;
// Update anchor if present // Update anchor if present
const editAnchor = this.container.querySelector('#pointer-edit'); const editAnchor = this.pointer.querySelector('#pointer-edit');
if (editAnchor && element) { if (editAnchor && element) {
const {editHref} = editAnchor.dataset; const {editHref} = editAnchor.dataset;
const elementId = element.id; const elementId = element.id;
// get the first 50 characters. // Get the first 50 characters.
const queryContent = element.textContent && element.textContent.substring(0, 50); const queryContent = element.textContent && element.textContent.substring(0, 50);
editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`; 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();
});
}
} }

View 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 {HTMLElement|Array} elements
* @param {function} callback * @param {function} callback
*/ */
export function onEnterPress(elements, callback) { function onKeyPress(key, elements, callback) {
if (!Array.isArray(elements)) { if (!Array.isArray(elements)) {
elements = [elements]; elements = [elements];
} }
const listener = event => { const listener = event => {
if (event.key === 'Enter') { if (event.key === key) {
callback(event); 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);
} }
/** /**

View File

@ -106,7 +106,7 @@ button {
display: block; display: block;
} }
.button.icon, .icon-button { .button.icon, .icon-button, .text-button.icon {
.svg-icon { .svg-icon {
margin-inline-end: 0; margin-inline-end: 0;
} }

View File

@ -302,6 +302,15 @@ body.flexbox {
display: none !important; display: none !important;
} }
.screen-reader-only {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
/** /**
* Border radiuses * Border radiuses
*/ */

View File

@ -198,10 +198,6 @@ body.tox-fullscreen, body.markdown-fullscreen {
.pointer { .pointer {
border: 1px solid #CCC; border: 1px solid #CCC;
@include lightDark(border-color, #ccc, #000); @include lightDark(border-color, #ccc, #000);
display: flex;
align-items: center;
justify-items: center;
padding: $-s $-s;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1); box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1);
@include lightDark(background-color, #fff, #333); @include lightDark(background-color, #fff, #333);
@ -241,16 +237,12 @@ body.tox-fullscreen, body.markdown-fullscreen {
border: 1px solid #DDD; border: 1px solid #DDD;
@include lightDark(border-color, #ddd, #000); @include lightDark(border-color, #ddd, #000);
color: #666; color: #666;
width: 172px; width: 160px;
z-index: 40; z-index: 40;
padding: 5px 10px; padding: 5px 10px;
} }
span.icon { .text-button {
fill: #444; @include lightDark(color, #444, #AAA);
cursor: pointer;
user-select: none;
display: inline-block;
line-height: 1;
} }
.input-group .button { .input-group .button {
line-height: 1; line-height: 1;

View File

@ -1,16 +1,36 @@
<div component="pointer" <div component="pointer"
option:pointer:page-id="{{ $page->id }}" option:pointer:page-id="{{ $page->id }}">
id="pointer" <div id="pointer"
class="pointer-container"> refs="pointer@pointer"
<div class="pointer anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" > tabindex="-1"
<span class="icon mr-xxs">@icon('link') @icon('include', ['style' => 'display:none;'])</span> aria-label="{{ trans('entities.pages_pointer_label') }}"
<div class="input-group inline block"> class="pointer-container">
<input refs="pointer@input" readonly="readonly" type="text" id="pointer-url" placeholder="url"> <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
<button refs="pointer@button" class="button outline icon" data-clipboard-target="#pointer-url" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button> <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> </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>
</div>
<button refs="pointer@section-mode-button" class="screen-reader-only">{{ trans('entities.pages_pointer_enter_mode') }}</button>
</div>

View File

@ -50,6 +50,13 @@ class PageTest extends TestCase
$resp->assertSeeText('Owned by ' . $owner->name); $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() public function test_page_creation_with_markdown_content()
{ {
$this->setSettings(['app-editor' => 'markdown']); $this->setSettings(['app-editor' => 'markdown']);