mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-30 07:32:39 +01:00
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:
parent
0323ebccd3
commit
88785aa71b
@ -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',
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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,31 +108,48 @@ export class Pointer extends Component {
|
||||
this.hidePointer();
|
||||
window.removeEventListener('scroll', scrollListener, {passive: true});
|
||||
};
|
||||
|
||||
element.parentElement.insertBefore(this.pointer, element);
|
||||
if (!keyboardMode) {
|
||||
window.addEventListener('scroll', scrollListener, {passive: true});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the pointer inputs/content for the given target element.
|
||||
* @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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -106,7 +106,7 @@ button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button.icon, .icon-button {
|
||||
.button.icon, .icon-button, .text-button.icon {
|
||||
.svg-icon {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
|
@ -1,12 +1,29 @@
|
||||
|
||||
<div component="pointer"
|
||||
option:pointer:page-id="{{ $page->id }}"
|
||||
id="pointer"
|
||||
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 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>
|
||||
<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') }}"
|
||||
@ -14,3 +31,6 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button refs="pointer@section-mode-button" class="screen-reader-only">{{ trans('entities.pages_pointer_enter_mode') }}</button>
|
||||
</div>
|
||||
|
@ -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']);
|
||||
|
Loading…
Reference in New Issue
Block a user