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
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
9 changed files with 147 additions and 71 deletions

View 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',

View 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);
});
}

View 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();
});
}
}

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 {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);
}
/**

View 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;
}

View 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
*/

View File

@ -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;

View 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>
<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);
}
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']);