2018-11-07 06:09:29 +00:00
|
|
|
/* global prefs */
|
|
|
|
/* exported scrollElementIntoView animateElement enforceInputRange $createLink
|
|
|
|
setupLivePrefs moveFocus */
|
2017-03-25 05:54:58 +00:00
|
|
|
'use strict';
|
|
|
|
|
2017-11-08 03:46:56 +00:00
|
|
|
if (!/^Win\d+/.test(navigator.platform)) {
|
2017-03-25 20:39:21 +00:00
|
|
|
document.documentElement.classList.add('non-windows');
|
|
|
|
}
|
|
|
|
|
2018-11-07 06:09:29 +00:00
|
|
|
// make querySelectorAll enumeration code readable
|
|
|
|
// FIXME: avoid extending native?
|
|
|
|
['forEach', 'some', 'indexOf', 'map'].forEach(method => {
|
|
|
|
NodeList.prototype[method] = Array.prototype[method];
|
|
|
|
});
|
|
|
|
|
2017-04-08 10:19:44 +00:00
|
|
|
// polyfill for old browsers to enable [...results] and for-of
|
|
|
|
for (const type of [NodeList, NamedNodeMap, HTMLCollection, HTMLAllCollection]) {
|
|
|
|
if (!type.prototype[Symbol.iterator]) {
|
|
|
|
type.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-12-02 16:54:54 +00:00
|
|
|
$.remove = (selector, base = document) => {
|
2017-12-02 18:41:28 +00:00
|
|
|
const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
|
2017-12-02 16:54:54 +00:00
|
|
|
if (el) {
|
|
|
|
el.remove();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
$$.remove = (selector, base = document) => {
|
|
|
|
for (const el of base.querySelectorAll(selector)) {
|
|
|
|
el.remove();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2017-08-18 07:15:51 +00:00
|
|
|
{
|
|
|
|
// display a full text tooltip on buttons with ellipsis overflow and no inherent title
|
|
|
|
const addTooltipsToEllipsized = () => {
|
2017-08-23 12:37:35 +00:00
|
|
|
for (const btn of document.getElementsByTagName('button')) {
|
2017-12-19 03:25:18 +00:00
|
|
|
if (btn.title && !btn.titleIsForEllipsis) {
|
2017-08-18 07:15:51 +00:00
|
|
|
continue;
|
|
|
|
}
|
2017-12-19 03:25:18 +00:00
|
|
|
const width = btn.offsetWidth;
|
|
|
|
if (!width || btn.preresizeClientWidth === width) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
btn.preresizeClientWidth = width;
|
|
|
|
if (btn.scrollWidth > width) {
|
2017-12-11 19:39:22 +00:00
|
|
|
const text = btn.textContent;
|
|
|
|
btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
|
2017-08-18 07:15:51 +00:00
|
|
|
btn.titleIsForEllipsis = true;
|
|
|
|
} else if (btn.title) {
|
|
|
|
btn.title = '';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
// enqueue after DOMContentLoaded/load events
|
2017-12-26 03:38:17 +00:00
|
|
|
setTimeout(addTooltipsToEllipsized, 500);
|
2017-08-18 07:15:51 +00:00
|
|
|
// throttle on continuous resizing
|
2017-09-14 01:15:58 +00:00
|
|
|
let timer;
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
clearTimeout(timer);
|
|
|
|
timer = setTimeout(addTooltipsToEllipsized, 100);
|
|
|
|
});
|
2017-08-18 07:15:51 +00:00
|
|
|
}
|
|
|
|
|
2017-09-13 15:35:34 +00:00
|
|
|
onDOMready().then(() => {
|
2017-12-02 16:54:54 +00:00
|
|
|
$.remove('#firefox-transitions-bug-suppressor');
|
2017-12-08 02:45:27 +00:00
|
|
|
initCollapsibles();
|
2017-12-12 18:33:41 +00:00
|
|
|
focusAccessibility();
|
2019-03-25 12:48:53 +00:00
|
|
|
if (!chrome.app && chrome.windows && typeof prefs !== 'undefined') {
|
|
|
|
// add favicon in Firefox
|
|
|
|
prefs.initializing.then(() => {
|
|
|
|
const iconset = ['', 'light/'][prefs.get('iconset')] || '';
|
|
|
|
for (const size of [38, 32, 19, 16]) {
|
|
|
|
document.head.appendChild($create('link', {
|
|
|
|
rel: 'icon',
|
|
|
|
href: `/images/icon/${iconset}${size}.png`,
|
|
|
|
sizes: size + 'x' + size,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2017-09-13 15:35:34 +00:00
|
|
|
});
|
2017-09-03 20:52:06 +00:00
|
|
|
|
2017-12-29 15:19:35 +00:00
|
|
|
// set language for CSS :lang and [FF-only] hyphenation
|
|
|
|
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
|
2020-07-30 01:30:00 +00:00
|
|
|
// avoid adding # to the page URL when clicking dummy links
|
|
|
|
document.addEventListener('click', e => {
|
|
|
|
if (e.target.closest('a[href="#"]')) {
|
|
|
|
e.preventDefault();
|
|
|
|
}
|
|
|
|
});
|
2020-08-02 03:50:12 +00:00
|
|
|
// update inputs on mousewheel when focused
|
|
|
|
document.addEventListener('wheel', event => {
|
|
|
|
const el = document.activeElement;
|
|
|
|
if (!el || el !== event.target && !el.contains(event.target)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (el.tagName === 'SELECT') {
|
2020-10-05 15:37:47 +00:00
|
|
|
const old = el.selectedIndex;
|
|
|
|
el.selectedIndex = Math.max(0, Math.min(el.length - 1, old + Math.sign(event.deltaY)));
|
|
|
|
if (el.selectedIndex !== old) {
|
|
|
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
|
|
}
|
2020-08-02 03:50:12 +00:00
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
event.stopImmediatePropagation();
|
|
|
|
}, {
|
|
|
|
capture: true,
|
|
|
|
passive: false,
|
|
|
|
});
|
2017-03-25 20:39:21 +00:00
|
|
|
|
2017-03-25 05:54:58 +00:00
|
|
|
function onDOMready() {
|
2017-07-16 18:02:00 +00:00
|
|
|
if (document.readyState !== 'loading') {
|
2017-03-25 05:54:58 +00:00
|
|
|
return Promise.resolve();
|
|
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
|
|
document.addEventListener('DOMContentLoaded', function _() {
|
|
|
|
document.removeEventListener('DOMContentLoaded', _);
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2017-11-29 22:13:13 +00:00
|
|
|
function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
|
2017-03-25 05:54:58 +00:00
|
|
|
// align to the top/bottom of the visible area if wasn't visible
|
2018-04-17 19:34:18 +00:00
|
|
|
if (!element.parentNode) return;
|
2017-11-29 21:54:40 +00:00
|
|
|
const {top, height} = element.getBoundingClientRect();
|
|
|
|
const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
|
2017-11-29 02:39:24 +00:00
|
|
|
const windowHeight = window.innerHeight;
|
2017-11-29 21:54:40 +00:00
|
|
|
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
|
|
|
|
top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
|
|
|
|
window.scrollBy(0, top - windowHeight / 2 + height);
|
2017-03-25 05:54:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2017-06-27 20:00:24 +00:00
|
|
|
function animateElement(
|
|
|
|
element, {
|
|
|
|
className = 'highlight',
|
|
|
|
removeExtraClasses = [],
|
2017-08-31 10:23:12 +00:00
|
|
|
onComplete,
|
2017-06-27 20:00:24 +00:00
|
|
|
} = {}) {
|
|
|
|
return element && new Promise(resolve => {
|
2017-03-25 05:54:58 +00:00
|
|
|
element.addEventListener('animationend', function _() {
|
|
|
|
element.removeEventListener('animationend', _);
|
2017-05-14 11:26:51 +00:00
|
|
|
element.classList.remove(
|
|
|
|
className,
|
2017-06-30 12:02:09 +00:00
|
|
|
// In Firefox, `resolve()` might be called one frame later.
|
|
|
|
// This is helpful to clean-up on the same frame
|
|
|
|
...removeExtraClasses
|
2017-05-14 11:26:51 +00:00
|
|
|
);
|
2017-08-31 10:23:12 +00:00
|
|
|
// TODO: investigate why animation restarts for 'display' modification in .then()
|
|
|
|
if (typeof onComplete === 'function') {
|
|
|
|
onComplete.call(element);
|
2017-03-25 05:54:58 +00:00
|
|
|
}
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
element.classList.add(className);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2017-04-11 10:51:40 +00:00
|
|
|
function enforceInputRange(element) {
|
|
|
|
const min = Number(element.min);
|
|
|
|
const max = Number(element.max);
|
2017-04-13 06:12:40 +00:00
|
|
|
const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true}));
|
|
|
|
const onChange = ({type}) => {
|
2017-07-16 18:02:00 +00:00
|
|
|
if (type === 'input' && element.checkValidity()) {
|
2017-04-13 06:12:40 +00:00
|
|
|
doNotify();
|
2017-07-16 18:02:00 +00:00
|
|
|
} else if (type === 'change' && !element.checkValidity()) {
|
2017-04-13 06:12:40 +00:00
|
|
|
element.value = Math.max(min, Math.min(max, Number(element.value)));
|
|
|
|
doNotify();
|
2017-04-11 10:51:40 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
element.addEventListener('change', onChange);
|
|
|
|
element.addEventListener('input', onChange);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2017-03-25 05:54:58 +00:00
|
|
|
function $(selector, base = document) {
|
2017-04-26 00:05:41 +00:00
|
|
|
// we have ids with . like #manage.onlyEnabled which looks like #id.class
|
2017-03-25 05:54:58 +00:00
|
|
|
// so since getElementById is superfast we'll try it anyway
|
|
|
|
const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
|
|
|
|
return byId || base.querySelector(selector);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function $$(selector, base = document) {
|
|
|
|
return [...base.querySelectorAll(selector)];
|
|
|
|
}
|
2017-04-17 20:25:32 +00:00
|
|
|
|
|
|
|
|
2017-12-03 21:12:09 +00:00
|
|
|
function $create(selector = 'div', properties, children) {
|
|
|
|
/*
|
|
|
|
$create('tag#id.class.class', ?[children])
|
|
|
|
$create('tag#id.class.class', ?textContentOrChildNode)
|
|
|
|
$create('tag#id.class.class', {properties}, ?[children])
|
|
|
|
$create('tag#id.class.class', {properties}, ?textContentOrChildNode)
|
|
|
|
tag is 'div' by default, #id and .class are optional
|
|
|
|
|
|
|
|
$create([children])
|
|
|
|
|
|
|
|
$create({propertiesAndOptions})
|
|
|
|
$create({propertiesAndOptions}, ?[children])
|
|
|
|
tag: string, default 'div'
|
|
|
|
appendChild: element/string or an array of elements/strings
|
|
|
|
dataset: object
|
|
|
|
any DOM property: assigned as is
|
|
|
|
|
|
|
|
tag may include namespace like 'ns:tag'
|
|
|
|
*/
|
|
|
|
let ns, tag, opt;
|
|
|
|
|
|
|
|
if (typeof selector === 'string') {
|
|
|
|
if (Array.isArray(properties) ||
|
|
|
|
properties instanceof Node ||
|
|
|
|
typeof properties !== 'object') {
|
|
|
|
opt = {};
|
|
|
|
children = properties;
|
|
|
|
} else {
|
|
|
|
opt = properties || {};
|
2018-04-12 17:42:01 +00:00
|
|
|
children = children || opt.appendChild;
|
2017-12-03 21:12:09 +00:00
|
|
|
}
|
|
|
|
const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
|
|
|
|
const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
|
|
|
|
const id = selector.slice(idStart + 1, classStart);
|
|
|
|
if (id) {
|
|
|
|
opt.id = id;
|
|
|
|
}
|
|
|
|
const cls = selector.slice(classStart + 1);
|
|
|
|
if (cls) {
|
|
|
|
opt[selector.includes(':') ? 'class' : 'className'] =
|
|
|
|
cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
|
|
|
|
}
|
|
|
|
tag = selector.slice(0, Math.min(idStart, classStart));
|
|
|
|
|
|
|
|
} else if (Array.isArray(selector)) {
|
|
|
|
tag = 'div';
|
|
|
|
opt = {};
|
|
|
|
children = selector;
|
|
|
|
|
|
|
|
} else {
|
|
|
|
opt = selector;
|
|
|
|
tag = opt.tag;
|
|
|
|
delete opt.tag;
|
|
|
|
children = opt.appendChild || properties;
|
|
|
|
delete opt.appendChild;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (tag && tag.includes(':')) {
|
|
|
|
([ns, tag] = tag.split(':'));
|
|
|
|
}
|
|
|
|
|
2017-04-24 11:07:35 +00:00
|
|
|
const element = ns
|
2017-07-16 18:02:00 +00:00
|
|
|
? document.createElementNS(ns === 'SVG' || ns === 'svg' ? 'http://www.w3.org/2000/svg' : ns, tag)
|
2018-03-22 22:44:40 +00:00
|
|
|
: tag === 'fragment'
|
|
|
|
? document.createDocumentFragment()
|
|
|
|
: document.createElement(tag || 'div');
|
2017-12-03 21:12:09 +00:00
|
|
|
|
|
|
|
for (const child of Array.isArray(children) ? children : [children]) {
|
2017-07-19 12:09:29 +00:00
|
|
|
if (child) {
|
|
|
|
element.appendChild(child instanceof Node ? child : document.createTextNode(child));
|
|
|
|
}
|
|
|
|
}
|
2017-12-03 21:12:09 +00:00
|
|
|
|
2017-04-17 20:25:32 +00:00
|
|
|
if (opt.dataset) {
|
|
|
|
Object.assign(element.dataset, opt.dataset);
|
|
|
|
delete opt.dataset;
|
|
|
|
}
|
2017-12-03 21:12:09 +00:00
|
|
|
|
2017-08-31 19:25:05 +00:00
|
|
|
if (opt.attributes) {
|
|
|
|
for (const attr in opt.attributes) {
|
|
|
|
element.setAttribute(attr, opt.attributes[attr]);
|
|
|
|
}
|
|
|
|
delete opt.attributes;
|
|
|
|
}
|
2017-12-03 21:12:09 +00:00
|
|
|
|
2017-04-24 11:07:35 +00:00
|
|
|
if (ns) {
|
|
|
|
for (const attr in opt) {
|
2017-12-04 16:14:04 +00:00
|
|
|
const i = attr.indexOf(':') + 1;
|
|
|
|
const attrNS = i && `http://www.w3.org/1999/${attr.slice(0, i - 1)}`;
|
|
|
|
element.setAttributeNS(attrNS || null, attr, opt[attr]);
|
2017-04-24 11:07:35 +00:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
Object.assign(element, opt);
|
|
|
|
}
|
2017-12-03 21:12:09 +00:00
|
|
|
|
2017-04-24 11:07:35 +00:00
|
|
|
return element;
|
2017-04-17 20:25:32 +00:00
|
|
|
}
|
2017-09-13 09:34:27 +00:00
|
|
|
|
|
|
|
|
2017-12-03 21:12:09 +00:00
|
|
|
function $createLink(href = '', content) {
|
2017-10-14 18:59:55 +00:00
|
|
|
const opt = {
|
2017-09-13 09:34:27 +00:00
|
|
|
tag: 'a',
|
|
|
|
target: '_blank',
|
2017-10-14 18:59:55 +00:00
|
|
|
rel: 'noopener'
|
|
|
|
};
|
|
|
|
if (typeof href === 'object') {
|
|
|
|
Object.assign(opt, href);
|
|
|
|
} else {
|
|
|
|
opt.href = href;
|
|
|
|
}
|
2017-12-03 21:12:09 +00:00
|
|
|
opt.appendChild = opt.appendChild || content;
|
|
|
|
return $create(opt);
|
2017-09-13 09:34:27 +00:00
|
|
|
}
|
2017-11-29 16:05:47 +00:00
|
|
|
|
|
|
|
|
2017-12-08 02:45:27 +00:00
|
|
|
// makes <details> with [data-pref] save/restore their state
|
2017-11-29 16:05:47 +00:00
|
|
|
function initCollapsibles({bindClickOn = 'h2'} = {}) {
|
|
|
|
const prefMap = {};
|
|
|
|
const elements = $$('details[data-pref]');
|
2017-12-08 02:45:27 +00:00
|
|
|
if (!elements.length) {
|
|
|
|
return;
|
|
|
|
}
|
2017-11-29 16:05:47 +00:00
|
|
|
|
|
|
|
for (const el of elements) {
|
|
|
|
const key = el.dataset.pref;
|
|
|
|
prefMap[key] = el;
|
|
|
|
el.open = prefs.get(key);
|
|
|
|
(bindClickOn && $(bindClickOn, el) || el).addEventListener('click', onClick);
|
|
|
|
}
|
|
|
|
|
|
|
|
prefs.subscribe(Object.keys(prefMap), (key, value) => {
|
|
|
|
const el = prefMap[key];
|
|
|
|
if (el.open !== value) {
|
|
|
|
el.open = value;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
function onClick(event) {
|
|
|
|
if (event.target.closest('.intercepts-click')) {
|
|
|
|
event.preventDefault();
|
|
|
|
} else {
|
|
|
|
setTimeout(saveState, 0, event.target.closest('details'));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function saveState(el) {
|
linter and compact layout improvements (#749)
* linter and compact layout improvements
Closes #748
While investigating the best way to fix linter scrolling, when I double-checked the compact layout, an old bug I meant to fix a long time ago was immediately apparent. Basically, the linter adds/removes errors as you type, causing the editor to bounce up and down, making it practically unusable.
This PR fixes scrolling, and also collapses options and the linter in the compact layout, but always shows the collapsed linter so you're aware of the error count without the content jumping. It also collapses options in the non-compact layout if the viewport is too short to accommodate them, factoring in the min-height of the linter. All automatic collapsing factors in whether a linter is active so they can adjust accordingly, and disables the setting of collapsed state prefs, since we're deciding the pref anyway, and this allows for re-expanding on resize based on the previous pref.
It's quite possible I failed to account for certain scenarios, so try to break it. Also think it's problematic for the linter to not always be visible if enabled, so I hooked up a 40px fixed header on scroll with just the linter in it for the compact layout.
A few other little details are included. I removed redundant line and column numbers spelled out at the end of lint messages to prevent horizontal overflow. I noticed that the expand/collapse prefs do not toggle correctly when clicking directly on the details-marker arrow. Simplest solution was covering them with the `h2` (we may wanna hook up the manager as well). Also, unrelated, but I switched to opacity to hide resizing sectioned editors, because `visibility: hidden;` breaks editor auto-focus.
If either of you guys wanna fix any bugs, or improve any code, feel free to just commit to this PR directly.
* linter and compact layout improvements
* linter and compact layout improvements
* No usercss scroll listener and delay header check
* Some code tweaks
2019-08-04 17:09:50 +00:00
|
|
|
if (!el.classList.contains('ignore-pref')) {
|
|
|
|
prefs.set(el.dataset.pref, el.open);
|
|
|
|
}
|
2017-11-29 16:05:47 +00:00
|
|
|
}
|
|
|
|
}
|
2017-12-12 18:33:41 +00:00
|
|
|
|
2018-09-06 16:08:56 +00:00
|
|
|
// Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
|
2017-12-12 18:33:41 +00:00
|
|
|
function focusAccessibility() {
|
2018-09-06 16:08:56 +00:00
|
|
|
// last event's focusedViaClick
|
|
|
|
focusAccessibility.lastFocusedViaClick = false;
|
|
|
|
// tags of focusable elements;
|
|
|
|
// to avoid a full layout recalc we modify the closest one
|
|
|
|
focusAccessibility.ELEMENTS = [
|
|
|
|
'a',
|
|
|
|
'button',
|
|
|
|
'input',
|
|
|
|
'textarea',
|
|
|
|
'label',
|
|
|
|
'select',
|
|
|
|
'summary',
|
|
|
|
];
|
|
|
|
// try to find a focusable parent for this many parentElement jumps:
|
2017-12-12 18:33:41 +00:00
|
|
|
const GIVE_UP_DEPTH = 4;
|
2018-09-06 16:08:56 +00:00
|
|
|
|
2017-12-12 18:33:41 +00:00
|
|
|
addEventListener('mousedown', suppressOutlineOnClick, {passive: true});
|
|
|
|
addEventListener('keydown', keepOutlineOnTab, {passive: true});
|
|
|
|
|
|
|
|
function suppressOutlineOnClick({target}) {
|
|
|
|
for (let el = target, i = 0; el && i++ < GIVE_UP_DEPTH; el = el.parentElement) {
|
2018-09-06 16:08:56 +00:00
|
|
|
if (focusAccessibility.ELEMENTS.includes(el.localName)) {
|
2018-09-06 17:42:48 +00:00
|
|
|
focusAccessibility.lastFocusedViaClick = true;
|
2017-12-12 18:33:41 +00:00
|
|
|
if (el.dataset.focusedViaClick === undefined) {
|
|
|
|
el.dataset.focusedViaClick = '';
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function keepOutlineOnTab(event) {
|
|
|
|
if (event.which === 9) {
|
2018-09-06 16:05:10 +00:00
|
|
|
focusAccessibility.lastFocusedViaClick = false;
|
2017-12-12 18:33:41 +00:00
|
|
|
setTimeout(keepOutlineOnTab, 0, true);
|
|
|
|
return;
|
|
|
|
} else if (event !== true) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
let el = document.activeElement;
|
2018-09-06 16:08:56 +00:00
|
|
|
if (!el || !focusAccessibility.ELEMENTS.includes(el.localName)) {
|
2017-12-12 18:33:41 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (el.dataset.focusedViaClick !== undefined) {
|
|
|
|
delete el.dataset.focusedViaClick;
|
|
|
|
}
|
|
|
|
el = el.closest('[data-focused-via-click]');
|
|
|
|
if (el) {
|
|
|
|
delete el.dataset.focusedViaClick;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2018-07-22 16:37:49 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Switches to the next/previous keyboard-focusable element
|
|
|
|
* @param {HTMLElement} rootElement
|
|
|
|
* @param {Number} step - for exmaple 1 or -1
|
2018-09-06 17:59:04 +00:00
|
|
|
* @returns {HTMLElement|false|undefined} -
|
|
|
|
* HTMLElement: focus changed,
|
|
|
|
* false: focus unchanged,
|
|
|
|
* undefined: nothing to focus
|
2018-07-22 16:37:49 +00:00
|
|
|
*/
|
|
|
|
function moveFocus(rootElement, step) {
|
|
|
|
const elements = [...rootElement.getElementsByTagName('*')];
|
|
|
|
const activeIndex = Math.max(0, elements.indexOf(document.activeElement));
|
|
|
|
const num = elements.length;
|
2018-09-06 17:59:04 +00:00
|
|
|
const {activeElement} = document;
|
2018-07-22 16:37:49 +00:00
|
|
|
for (let i = 1; i < num; i++) {
|
|
|
|
const elementIndex = (activeIndex + i * step + num) % num;
|
|
|
|
// we don't use positive tabindex so we stop at any valid value
|
|
|
|
const el = elements[elementIndex];
|
|
|
|
if (!el.disabled && el.tabIndex >= 0) {
|
|
|
|
el.focus();
|
2018-09-06 17:59:04 +00:00
|
|
|
return activeElement !== el && el;
|
2018-07-22 16:37:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2018-11-07 06:09:29 +00:00
|
|
|
|
|
|
|
// Accepts an array of pref names (values are fetched via prefs.get)
|
|
|
|
// and establishes a two-way connection between the document elements and the actual prefs
|
|
|
|
function setupLivePrefs(
|
|
|
|
IDs = Object.getOwnPropertyNames(prefs.defaults)
|
|
|
|
.filter(id => $('#' + id))
|
|
|
|
) {
|
|
|
|
for (const id of IDs) {
|
|
|
|
const element = $('#' + id);
|
|
|
|
updateElement({id, element, force: true});
|
|
|
|
element.addEventListener('change', onChange);
|
|
|
|
}
|
|
|
|
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
|
|
|
|
|
|
|
|
function onChange() {
|
|
|
|
const value = getInputValue(this);
|
|
|
|
if (prefs.get(this.id) !== value) {
|
|
|
|
prefs.set(this.id, value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function updateElement({
|
|
|
|
id,
|
|
|
|
value = prefs.get(id),
|
|
|
|
element = $('#' + id),
|
|
|
|
force,
|
|
|
|
}) {
|
|
|
|
if (!element) {
|
|
|
|
prefs.unsubscribe(IDs, updateElement);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
setInputValue(element, value, force);
|
|
|
|
}
|
|
|
|
function getInputValue(input) {
|
|
|
|
if (input.type === 'checkbox') {
|
|
|
|
return input.checked;
|
|
|
|
}
|
|
|
|
if (input.type === 'number') {
|
|
|
|
return Number(input.value);
|
|
|
|
}
|
|
|
|
return input.value;
|
|
|
|
}
|
|
|
|
function setInputValue(input, value, force = false) {
|
|
|
|
if (force || getInputValue(input) !== value) {
|
|
|
|
if (input.type === 'checkbox') {
|
|
|
|
input.checked = value;
|
|
|
|
} else {
|
|
|
|
input.value = value;
|
|
|
|
}
|
|
|
|
input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|