TS: Converted dom and keyboard nav services
Some checks failed
lint-js / build (push) Has been cancelled
test-js / build (push) Has been cancelled

This commit is contained in:
Dan Brown 2024-10-11 21:55:51 +01:00
parent f41c02cbd7
commit 209fa04752
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
28 changed files with 87 additions and 98 deletions

View File

@ -10,7 +10,7 @@ const isProd = process.argv[2] === 'production';
// Gather our input files // Gather our input files
const entryPoints = { const entryPoints = {
app: path.join(__dirname, '../../resources/js/app.js'), app: path.join(__dirname, '../../resources/js/app.ts'),
code: path.join(__dirname, '../../resources/js/code/index.mjs'), code: path.join(__dirname, '../../resources/js/code/index.mjs'),
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'), 'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'), markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),

View File

@ -1,4 +1,4 @@
import {onChildEvent} from '../services/dom'; import {onChildEvent} from '../services/dom.ts';
import {uniqueId} from '../services/util.ts'; import {uniqueId} from '../services/util.ts';
import {Component} from './component'; import {Component} from './component';

View File

@ -1,4 +1,4 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class AjaxDeleteRow extends Component { export class AjaxDeleteRow extends Component {

View File

@ -1,4 +1,4 @@
import {onEnterPress, onSelect} from '../services/dom'; import {onEnterPress, onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -1,4 +1,4 @@
import {showLoading} from '../services/dom'; import {showLoading} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class Attachments extends Component { export class Attachments extends Component {

View File

@ -1,7 +1,7 @@
import {escapeHtml} from '../services/util.ts'; import {escapeHtml} from '../services/util.ts';
import {onChildEvent} from '../services/dom'; import {onChildEvent} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
const ajaxCache = {}; const ajaxCache = {};

View File

@ -1,6 +1,6 @@
import Sortable, {MultiDrag} from 'sortablejs'; import Sortable, {MultiDrag} from 'sortablejs';
import {Component} from './component'; import {Component} from './component';
import {htmlToDom} from '../services/dom'; import {htmlToDom} from '../services/dom.ts';
// Auto sort control // Auto sort control
const sortOperations = { const sortOperations = {

View File

@ -1,4 +1,4 @@
import {onChildEvent, onEnterPress, onSelect} from '../services/dom'; import {onChildEvent, onEnterPress, onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class CodeEditor extends Component { export class CodeEditor extends Component {

View File

@ -1,4 +1,4 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -1,5 +1,5 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -2,7 +2,7 @@ import {Component} from './component';
import {Clipboard} from '../services/clipboard.ts'; import {Clipboard} from '../services/clipboard.ts';
import { import {
elem, getLoading, onSelect, removeLoading, elem, getLoading, onSelect, removeLoading,
} from '../services/dom'; } from '../services/dom.ts';
export class Dropzone extends Component { export class Dropzone extends Component {

View File

@ -1,4 +1,4 @@
import {htmlToDom} from '../services/dom'; import {htmlToDom} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class EntityPermissions extends Component { export class EntityPermissions extends Component {

View File

@ -1,4 +1,4 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class EntitySearch extends Component { export class EntitySearch extends Component {

View File

@ -1,4 +1,4 @@
import {onChildEvent} from '../services/dom'; import {onChildEvent} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -1,4 +1,4 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -1,6 +1,6 @@
import {htmlToDom} from '../services/dom'; import {htmlToDom} from '../services/dom.ts';
import {debounce} from '../services/util.ts'; import {debounce} from '../services/util.ts';
import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -1,6 +1,6 @@
import { import {
onChildEvent, onSelect, removeLoading, showLoading, onChildEvent, onSelect, removeLoading, showLoading,
} from '../services/dom'; } from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class ImageManager extends Component { export class ImageManager extends Component {

View File

@ -1,4 +1,4 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class OptionalInput extends Component { export class OptionalInput extends Component {

View File

@ -1,5 +1,5 @@
import {Component} from './component'; import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom.ts';
import {buildForInput} from '../wysiwyg-tinymce/config'; import {buildForInput} from '../wysiwyg-tinymce/config';
export class PageComment extends Component { export class PageComment extends Component {

View File

@ -1,5 +1,5 @@
import {Component} from './component'; import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom.ts';
import {buildForInput} from '../wysiwyg-tinymce/config'; import {buildForInput} from '../wysiwyg-tinymce/config';
export class PageComments extends Component { export class PageComments extends Component {

View File

@ -1,4 +1,4 @@
import * as DOM from '../services/dom'; import * as DOM from '../services/dom.ts';
import {scrollAndHighlightElement} from '../services/util.ts'; import {scrollAndHighlightElement} from '../services/util.ts';
import {Component} from './component'; import {Component} from './component';

View File

@ -1,4 +1,4 @@
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {debounce} from '../services/util.ts'; import {debounce} from '../services/util.ts';
import {Component} from './component'; import {Component} from './component';
import {utcTimeStampToLocalTime} from '../services/dates.ts'; import {utcTimeStampToLocalTime} from '../services/dates.ts';

View File

@ -1,4 +1,4 @@
import * as DOM from '../services/dom'; import * as DOM from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
import {copyTextToClipboard} from '../services/clipboard.ts'; import {copyTextToClipboard} from '../services/clipboard.ts';

View File

@ -1,5 +1,5 @@
import {fadeIn, fadeOut} from '../services/animations.ts'; import {fadeIn, fadeOut} from '../services/animations.ts';
import {onSelect} from '../services/dom'; import {onSelect} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
/** /**

View File

@ -1,4 +1,4 @@
import * as DOM from '../services/dom'; import * as DOM from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class TemplateManager extends Component { export class TemplateManager extends Component {

View File

@ -1,4 +1,4 @@
import {onChildEvent} from '../services/dom'; import {onChildEvent} from '../services/dom.ts';
import {Component} from './component'; import {Component} from './component';
export class UserSelect extends Component { export class UserSelect extends Component {

View File

@ -1,12 +1,15 @@
/**
* Check if the given param is a HTMLElement
*/
export function isHTMLElement(el: any): el is HTMLElement {
return el instanceof HTMLElement;
}
/** /**
* Create a new element with the given attrs and children. * Create a new element with the given attrs and children.
* Children can be a string for text nodes or other elements. * Children can be a string for text nodes or other elements.
* @param {String} tagName
* @param {Object<String, String>} attrs
* @param {Element[]|String[]}children
* @return {*}
*/ */
export function elem(tagName, attrs = {}, children = []) { export function elem(tagName: string, attrs: Record<string, string> = {}, children: Element[]|string[] = []): HTMLElement {
const el = document.createElement(tagName); const el = document.createElement(tagName);
for (const [key, val] of Object.entries(attrs)) { for (const [key, val] of Object.entries(attrs)) {
@ -30,10 +33,8 @@ export function elem(tagName, attrs = {}, children = []) {
/** /**
* Run the given callback against each element that matches the given selector. * Run the given callback against each element that matches the given selector.
* @param {String} selector
* @param {Function<Element>} callback
*/ */
export function forEach(selector, callback) { export function forEach(selector: string, callback: (el: Element) => any) {
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector);
for (const element of elements) { for (const element of elements) {
callback(element); callback(element);
@ -42,11 +43,8 @@ export function forEach(selector, callback) {
/** /**
* Helper to listen to multiple DOM events * Helper to listen to multiple DOM events
* @param {Element} listenerElement
* @param {Array<String>} events
* @param {Function<Event>} callback
*/ */
export function onEvents(listenerElement, events, callback) { export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
for (const eventName of events) { for (const eventName of events) {
listenerElement.addEventListener(eventName, callback); listenerElement.addEventListener(eventName, callback);
} }
@ -55,10 +53,8 @@ export function onEvents(listenerElement, events, callback) {
/** /**
* Helper to run an action when an element is selected. * Helper to run an action when an element is selected.
* A "select" is made to be accessible, So can be a click, space-press or enter-press. * A "select" is made to be accessible, So can be a click, space-press or enter-press.
* @param {HTMLElement|Array} elements
* @param {function} callback
*/ */
export function onSelect(elements, callback) { export function onSelect(elements: HTMLElement|HTMLElement[], callback: (e: Event) => any): void {
if (!Array.isArray(elements)) { if (!Array.isArray(elements)) {
elements = [elements]; elements = [elements];
} }
@ -76,16 +72,13 @@ export function onSelect(elements, callback) {
/** /**
* Listen to key 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
*/ */
function onKeyPress(key, elements, callback) { function onKeyPress(key: string, elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
if (!Array.isArray(elements)) { if (!Array.isArray(elements)) {
elements = [elements]; elements = [elements];
} }
const listener = event => { const listener = (event: KeyboardEvent) => {
if (event.key === key) { if (event.key === key) {
callback(event); callback(event);
} }
@ -96,19 +89,15 @@ function onKeyPress(key, elements, callback) {
/** /**
* Listen to enter press on the given element(s). * Listen to enter press on the given element(s).
* @param {HTMLElement|Array} elements
* @param {function} callback
*/ */
export function onEnterPress(elements, callback) { export function onEnterPress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
onKeyPress('Enter', elements, callback); onKeyPress('Enter', elements, callback);
} }
/** /**
* Listen to escape press on the given element(s). * Listen to escape press on the given element(s).
* @param {HTMLElement|Array} elements
* @param {function} callback
*/ */
export function onEscapePress(elements, callback) { export function onEscapePress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
onKeyPress('Escape', elements, callback); onKeyPress('Escape', elements, callback);
} }
@ -116,14 +105,15 @@ export function onEscapePress(elements, callback) {
* Set a listener on an element for an event emitted by a child * Set a listener on an element for an event emitted by a child
* matching the given childSelector param. * matching the given childSelector param.
* Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback) * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback)
* @param {Element} listenerElement
* @param {String} childSelector
* @param {String} eventName
* @param {Function} callback
*/ */
export function onChildEvent(listenerElement, childSelector, eventName, callback) { export function onChildEvent(
listenerElement.addEventListener(eventName, event => { listenerElement: HTMLElement,
const matchingChild = event.target.closest(childSelector); childSelector: string,
eventName: string,
callback: (this: HTMLElement, e: Event, child: HTMLElement) => any
): void {
listenerElement.addEventListener(eventName, (event: Event) => {
const matchingChild = (event.target as HTMLElement|null)?.closest(childSelector) as HTMLElement;
if (matchingChild) { if (matchingChild) {
callback.call(matchingChild, event, matchingChild); callback.call(matchingChild, event, matchingChild);
} }
@ -132,16 +122,13 @@ export function onChildEvent(listenerElement, childSelector, eventName, callback
/** /**
* Look for elements that match the given selector and contain the given text. * Look for elements that match the given selector and contain the given text.
* Is case insensitive and returns the first result or null if nothing is found. * Is case-insensitive and returns the first result or null if nothing is found.
* @param {String} selector
* @param {String} text
* @returns {Element}
*/ */
export function findText(selector, text) { export function findText(selector: string, text: string): Element|null {
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector);
text = text.toLowerCase(); text = text.toLowerCase();
for (const element of elements) { for (const element of elements) {
if (element.textContent.toLowerCase().includes(text)) { if ((element.textContent || '').toLowerCase().includes(text) && isHTMLElement(element)) {
return element; return element;
} }
} }
@ -151,17 +138,15 @@ export function findText(selector, text) {
/** /**
* Show a loading indicator in the given element. * Show a loading indicator in the given element.
* This will effectively clear the element. * This will effectively clear the element.
* @param {Element} element
*/ */
export function showLoading(element) { export function showLoading(element: HTMLElement): void {
element.innerHTML = '<div class="loading-container"><div></div><div></div><div></div></div>'; element.innerHTML = '<div class="loading-container"><div></div><div></div><div></div></div>';
} }
/** /**
* Get a loading element indicator element. * Get a loading element indicator element.
* @returns {Element}
*/ */
export function getLoading() { export function getLoading(): HTMLElement {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.classList.add('loading-container'); wrap.classList.add('loading-container');
wrap.innerHTML = '<div></div><div></div><div></div>'; wrap.innerHTML = '<div></div><div></div><div></div>';
@ -170,9 +155,8 @@ export function getLoading() {
/** /**
* Remove any loading indicators within the given element. * Remove any loading indicators within the given element.
* @param {Element} element
*/ */
export function removeLoading(element) { export function removeLoading(element: HTMLElement): void {
const loadingEls = element.querySelectorAll('.loading-container'); const loadingEls = element.querySelectorAll('.loading-container');
for (const el of loadingEls) { for (const el of loadingEls) {
el.remove(); el.remove();
@ -182,12 +166,15 @@ export function removeLoading(element) {
/** /**
* Convert the given html data into a live DOM element. * Convert the given html data into a live DOM element.
* Initiates any components defined in the data. * Initiates any components defined in the data.
* @param {String} html
* @returns {Element}
*/ */
export function htmlToDom(html) { export function htmlToDom(html: string): HTMLElement {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.innerHTML = html; wrap.innerHTML = html;
window.$components.init(wrap); window.$components.init(wrap);
return wrap.children[0]; const firstChild = wrap.children[0];
if (!isHTMLElement(firstChild)) {
throw new Error('Could not find child HTMLElement when creating DOM element from HTML');
}
return firstChild;
} }

View File

@ -1,14 +1,17 @@
import {isHTMLElement} from "./dom";
type OptionalKeyEventHandler = ((e: KeyboardEvent) => any)|null;
/** /**
* Handle common keyboard navigation events within a given container. * Handle common keyboard navigation events within a given container.
*/ */
export class KeyboardNavigationHandler { export class KeyboardNavigationHandler {
/** protected containers: HTMLElement[];
* @param {Element} container protected onEscape: OptionalKeyEventHandler;
* @param {Function|null} onEscape protected onEnter: OptionalKeyEventHandler;
* @param {Function|null} onEnter
*/ constructor(container: HTMLElement, onEscape: OptionalKeyEventHandler = null, onEnter: OptionalKeyEventHandler = null) {
constructor(container, onEscape = null, onEnter = null) {
this.containers = [container]; this.containers = [container];
this.onEscape = onEscape; this.onEscape = onEscape;
this.onEnter = onEnter; this.onEnter = onEnter;
@ -18,9 +21,8 @@ export class KeyboardNavigationHandler {
/** /**
* Also share the keyboard event handling to the given element. * Also share the keyboard event handling to the given element.
* Only elements within the original container are considered focusable though. * Only elements within the original container are considered focusable though.
* @param {Element} element
*/ */
shareHandlingToEl(element) { shareHandlingToEl(element: HTMLElement) {
this.containers.push(element); this.containers.push(element);
element.addEventListener('keydown', this.#keydownHandler.bind(this)); element.addEventListener('keydown', this.#keydownHandler.bind(this));
} }
@ -30,7 +32,8 @@ export class KeyboardNavigationHandler {
*/ */
focusNext() { focusNext() {
const focusable = this.#getFocusable(); const focusable = this.#getFocusable();
const currentIndex = focusable.indexOf(document.activeElement); const activeEl = document.activeElement;
const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
let newIndex = currentIndex + 1; let newIndex = currentIndex + 1;
if (newIndex >= focusable.length) { if (newIndex >= focusable.length) {
newIndex = 0; newIndex = 0;
@ -44,7 +47,8 @@ export class KeyboardNavigationHandler {
*/ */
focusPrevious() { focusPrevious() {
const focusable = this.#getFocusable(); const focusable = this.#getFocusable();
const currentIndex = focusable.indexOf(document.activeElement); const activeEl = document.activeElement;
const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1;
let newIndex = currentIndex - 1; let newIndex = currentIndex - 1;
if (newIndex < 0) { if (newIndex < 0) {
newIndex = focusable.length - 1; newIndex = focusable.length - 1;
@ -53,12 +57,9 @@ export class KeyboardNavigationHandler {
focusable[newIndex].focus(); focusable[newIndex].focus();
} }
/** #keydownHandler(event: KeyboardEvent) {
* @param {KeyboardEvent} event
*/
#keydownHandler(event) {
// Ignore certain key events in inputs to allow text editing. // Ignore certain key events in inputs to allow text editing.
if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { if (isHTMLElement(event.target) && event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
return; return;
} }
@ -71,7 +72,7 @@ export class KeyboardNavigationHandler {
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
if (this.onEscape) { if (this.onEscape) {
this.onEscape(event); this.onEscape(event);
} else if (document.activeElement) { } else if (isHTMLElement(document.activeElement)) {
document.activeElement.blur(); document.activeElement.blur();
} }
} else if (event.key === 'Enter' && this.onEnter) { } else if (event.key === 'Enter' && this.onEnter) {
@ -81,14 +82,15 @@ export class KeyboardNavigationHandler {
/** /**
* Get an array of focusable elements within the current containers. * Get an array of focusable elements within the current containers.
* @returns {Element[]}
*/ */
#getFocusable() { #getFocusable(): HTMLElement[] {
const focusable = []; const focusable: HTMLElement[] = [];
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])'; const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
for (const container of this.containers) { for (const container of this.containers) {
focusable.push(...container.querySelectorAll(selector)); const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e));
focusable.push(...toAdd);
} }
return focusable; return focusable;
} }