From ab9ad406cbdb566e14942d928a618536c126089f Mon Sep 17 00:00:00 2001 From: NaitLee Date: Tue, 26 Apr 2022 17:24:46 +0800 Subject: [PATCH] Accessibility: keyboard mode by Tab --- www/index.html | 75 +++++++++++++++++--------------- www/lang/en-US.json | 7 ++- www/lang/zh-CN.json | 7 ++- www/main.css | 44 +++++++++++++++++-- www/main.js | 104 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 42 deletions(-) diff --git a/www/index.html b/www/index.html index a5afad3..94e95de 100644 --- a/www/index.html +++ b/www/index.html @@ -16,73 +16,73 @@
-
Print
-
Help
-
Settings
+ + +
@@ -107,7 +107,7 @@ - +
@@ -117,29 +117,33 @@
-

Language

-
-
+

+ 🌎 + Language +

+ +

Press Tab to control with keyboard

Layout

- +
- +
- +
- +
- +
- +
- +
@@ -164,10 +168,11 @@

Please enable JavaScript!
JavaScript License Information + data-jslicense="1" tabindex="1" >JavaScript License Information

+
\ No newline at end of file diff --git a/www/lang/en-US.json b/www/lang/en-US.json index 36badc4..c94a6c2 100644 --- a/www/lang/en-US.json +++ b/www/lang/en-US.json @@ -101,5 +101,10 @@ "high-contrast": "High Contrast", "welcome": "Welcome!", "copyright-and-license": "Copyright and License", - "some-rights-reserved": "Some rights reserved." + "some-rights-reserved": "Some rights reserved.", + "ENTER": "Enter", + "SPACE": "Space", + "ESCAPE": "Esc", + "TAB": "Tab", + "press-tab-to-control-with-keyboard": "Press Tab to control with keyboard" } \ No newline at end of file diff --git a/www/lang/zh-CN.json b/www/lang/zh-CN.json index ab74e6f..3d76d89 100644 --- a/www/lang/zh-CN.json +++ b/www/lang/zh-CN.json @@ -96,5 +96,10 @@ "high-contrast": "高对比度", "welcome": "欢迎!", "copyright-and-license": "版权与许可", - "some-rights-reserved": "保留一些权利。" + "some-rights-reserved": "保留一些权利。", + "ENTER": "回车", + "SPACE": "空格", + "ESCAPE": "ESC", + "TAB": "Tab", + "press-tab-to-control-with-keyboard": "按下 Tab 以使用键盘操作" } \ No newline at end of file diff --git a/www/main.css b/www/main.css index d235a48..ab7c830 100644 --- a/www/main.css +++ b/www/main.css @@ -20,6 +20,7 @@ --notice-note: rgba(0, 255, 0, 0.2); --notice-warn: rgba(255, 128, 0, 0.2); --notice-error: rgba(255, 0, 0, 0.2); + --shade: rgba(238, 238, 238, 0.5); } body { @@ -141,7 +142,7 @@ main>.menu-side { position: sticky; top: 0; height: 100%; - overflow: auto; + /* overflow: auto; */ margin: var(--span); min-width: 12em; } @@ -163,8 +164,15 @@ main>.menu-side>.menu { line-height: 2em; text-align: center; cursor: pointer; + border: none; border-top: var(--border) solid var(--fore-color); border-bottom: var(--border) solid transparent; + padding: 0; + margin: 0; +} +.compact-button:hover { + padding: 0; + margin: 0; } .compact-button.active { border: var(--border) solid var(--fore-color); @@ -339,7 +347,7 @@ hr { #accessibility>*:nth-child(1) { flex-grow: 1; } -#select-language { +#select-language[multiple] { width: calc(100% - var(--span-double)); height: 8em; border: var(--border) solid var(--fore-color); @@ -408,6 +416,33 @@ hr { #loading-screen>.dots>span:nth-child(2) { animation-delay: 0.3s; } #loading-screen>.dots>span:nth-child(3) { animation-delay: 0.6s; } +#keyboard-shortcuts-layer { + position: absolute; + top: 0; + left: 0; + width: 100%; + overflow: visible; + pointer-events: all; + z-index: 2; +} +#keyboard-shortcuts-layer span { + display: inline-block; + position: absolute; + /* border: var(--border) dotted var(--fore-color); */ + background-color: var(--shade); + padding: var(--span-half) var(--span); + white-space: pre; + line-height: 1em; + font-family: 'DejaVu Sans Mono', 'Consolas', monospace; + transform: translateY(calc(var(--font-size) * -1)); +} +body.force-rtl #keyboard-shortcuts-layer span { + transform: translate( + calc(var(--font-size) * 2), + calc(var(--font-size) * -1) + ); +} + a { transition: all var(--anim-time) ease-out; } @@ -485,7 +520,7 @@ a { } @media (prefers-color-scheme: dark) { - :root { --fore-color: #eee; --back-color: #333; } + :root { --fore-color: #eee; --back-color: #333; --shade: rgba(51, 51, 51, 0.5); } body, .shade { transition: background-color calc(var(--anim-time) * 2) ease-in; } a:link, a:visited { color: #66f; } a:hover, a:active { color: #77f; } @@ -493,7 +528,7 @@ a { #control-document, .logo { filter: brightness(0.6); } } /* so silly... */ -body.dark { --fore-color: #eee; --back-color: #333; } +body.dark { --fore-color: #eee; --back-color: #333; --shade: rgba(51, 51, 51, 0.5); } body.dark, .shade { transition: background-color calc(var(--anim-time) * 2) ease-in; } body.dark a:link, body.dark a:visited { color: #66f; } body.dark a:hover, body.dark a:active { color: #77f; } @@ -541,6 +576,7 @@ body.high-contrast { --notice-note: transparent; --notice-warn: transparent; --notice-error: transparent; + --shade: rgba(0, 0, 0, 0.8); transition-duration: 0s; } body.high-contrast .shade { transition-duration: 0s; opacity: 1; } diff --git a/www/main.js b/www/main.js index 478e3b8..20cc467 100644 --- a/www/main.js +++ b/www/main.js @@ -83,9 +83,11 @@ const Dialog = (function() { last_choices = []; dialog_input.value = ''; dialog_input.style.display = have_input ? 'unset' : 'none'; + let index = 1; for (let choice of choices) { let button = document.createElement('button'); button.setAttribute('data-i18n', choice); + button.setAttribute('data-key', index++); button.innerText = i18n(choice); if (!have_input) button.addEventListener('click', () => dialog_input.value = choice); @@ -403,7 +405,7 @@ function applyI18nToDom(doc) { } async function initI18n() { if (typeof i18n === 'undefined') return; - /** @type {HTMLOptionElement} */ + /** @type {HTMLSelectElement} */ let language_options = document.getElementById('select-language'); /** @type {{ [code: string]: string }} */ let list = await fetch('/lang/list.json').then(r => r.json()); @@ -412,14 +414,20 @@ async function initI18n() { i18n.add(value, await fetch(`/lang/${value}.json`).then(r => r.json()), true); applyI18nToDom(); } + language_options.addEventListener('change', () => { + language_options.selectedOptions.item(0).click(); + }); for (let code in list) { let option = document.createElement('option'); option.value = code; option.innerText = list[code]; option.addEventListener('click', (event) => { + /** @type {HTMLOptionElement} */ let option = event.currentTarget; let value = option.value; + language_options.selectedIndex = option.index; use_language(value); + Notice.note('welcome'); }); language_options.appendChild(option); } @@ -455,6 +463,97 @@ async function testI18n(lang) { , true); } +function isHidden(element) { + let parents = [element]; + while (parents[0].parentElement) + parents.unshift(parents[0].parentElement); + return parents.some(e => { + let rect = e.getBoundingClientRect(); + return ( + e.classList.contains('hidden') || + e.classList.contains('hard-hidden') || + e.style.display == 'none' || + rect.width == 0 || rect.height == 0 || + rect.x < 0 || rect.y < 0 || + e.style.visibility == 'none' || + e.style.opacity == '0' + ); + }); +} + +function initKeyboardShortcuts() { + const layer = document.getElementById('keyboard-shortcuts-layer'); + const dialog = document.getElementById('dialog'); + let key, keys = 'qwertyuiopasdfghjklzxcvbnm'; + let focused, onbreak = false, started = false; + let inputs, shortcuts = {}; + const mark_keys = () => { + if (dialog.classList.contains('hidden')) + inputs = Array.from(document.querySelectorAll('*[data-key]')); + else inputs = Array.from(document.querySelectorAll('#dialog *[data-key]')); + /** @type {{ [key: string]: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement }} */ + let key_index = 0; + shortcuts = {}; + for (let input of inputs) { + if (isHidden(input)) continue; + let key = input.getAttribute('data-key') || keys[key_index++]; + shortcuts[key] = input; + } + Array.from(layer.children).forEach(e => e.remove()); + for (let key in shortcuts) { + if (onbreak) { + let rect = focused.getBoundingClientRect(); + let span = document.createElement('span'); + span.innerText = i18n('ESCAPE'); + span.style.top = rect.y + 'px'; + span.style.left = rect.x + 'px'; + layer.appendChild(span); + break; + } + let input = shortcuts[key]; + let position = input.getBoundingClientRect(); + let span = document.createElement('span'); + span.innerText = i18n(key.toUpperCase().replace(' ', 'SPACE')); + span.style.top = position.y + 'px'; + span.style.right = (window.innerWidth - position.x) + 'px'; + layer.appendChild(span); + } + } + const start = () => setInterval(mark_keys, 1000); + document.body.addEventListener('keyup', (event) => { + document.body.addEventListener('keyup', () => requestAnimationFrame(mark_keys), { once: true }); + key = event.key; + if (key === 'Tab') { + if (!started) { start(); started = true; } + mark_keys(); + return; + } + let input = shortcuts[key]; + if (input) { + switch (input.type || input.tagName) { + case 'range': + case 'text': + case 'number': + case 'tel': + case 'date': + case 'select-one': + case 'select-multiple': + case 'DIV': + case 'TEXTAREA': + input.focus(); + onbreak = true; + break; + default: + input.click(); + } + focused = input; + } else if (key === 'Escape' && focused) { + focused.blur(); + onbreak = !onbreak; + } + }); +} + class Main { promise; /** @type {CanvasController} */ @@ -525,6 +624,9 @@ class Main { this.attachSetter('#flip-v', 'change', 'flip_v'); this.attachSetter('#dump', 'change', 'dump'); await this.loadConfig(); + if (this.settings['is_android']) + document.getElementById('select-language').multiple = false; + initKeyboardShortcuts(); this.searchDevices(); document.querySelector('main').classList.remove('hard-hidden'); document.getElementById('loading-screen').classList.add('hidden');