mirror of
https://github.com/go-gitea/gitea.git
synced 2025-03-16 18:50:12 +03:00
Refactor global init code and add more comments (#33755)
Follow up #33748 Now there are 3 "global" functions: * registerGlobalSelectorFunc: for all elements matching the selector, eg: `.ui.dropdown` * registerGlobalInitFunc: for `data-global-init="initInputAutoFocusEnd"` * registerGlobalEventFunc: for `data-global-click="onCommentReactionButtonClick"` And introduce `initGlobalInput` to replace old `initAutoFocusEnd` and `attachDirAuto`, use `data-global-init` to replace fragile `.js-autofocus-end` selector. Another benefit is that by the new approach, no matter how many times `registerGlobalInitFunc` is called, we only need to do one "querySelectorAll" in the last step, it could slightly improve the performance.
This commit is contained in:
parent
5cbdf83f70
commit
27bf63ad20
@ -62,6 +62,10 @@ func TestSanitizer(t *testing.T) {
|
||||
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
|
||||
`<a href="vbscript:no">bad</a>`, `bad`,
|
||||
`<a href="data:1234">bad</a>`, `bad`,
|
||||
|
||||
// Some classes and attributes are used by the frontend framework and will execute JS code, so make sure they are removed
|
||||
`<div class="link-action" data-attr-class="foo" data-url="xxx">txt</div>`, `<div data-attr-class="foo">txt</div>`,
|
||||
`<div class="form-fetch-action" data-markdown-generated-content="bar" data-global-init="a" data-global-click="b">txt</div>`, `<div data-markdown-generated-content="bar">txt</div>`,
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCases); i += 2 {
|
||||
|
@ -1,20 +1,20 @@
|
||||
{{if .Flash.ErrorMsg}}
|
||||
{{- if .Flash.ErrorMsg -}}
|
||||
<div class="ui negative message flash-message flash-error">
|
||||
<p>{{.Flash.ErrorMsg | SanitizeHTML}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Flash.SuccessMsg}}
|
||||
{{- end -}}
|
||||
{{- if .Flash.SuccessMsg -}}
|
||||
<div class="ui positive message flash-message flash-success">
|
||||
<p>{{.Flash.SuccessMsg | SanitizeHTML}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Flash.InfoMsg}}
|
||||
{{- end -}}
|
||||
{{- if .Flash.InfoMsg -}}
|
||||
<div class="ui info message flash-message flash-info">
|
||||
<p>{{.Flash.InfoMsg | SanitizeHTML}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Flash.WarningMsg}}
|
||||
{{- end -}}
|
||||
{{- if .Flash.WarningMsg -}}
|
||||
<div class="ui warning message flash-message flash-warning">
|
||||
<p>{{.Flash.WarningMsg | SanitizeHTML}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{- end -}}
|
||||
|
@ -1,6 +1,4 @@
|
||||
{{if .Flash}}
|
||||
{{template "base/alert" .}}
|
||||
{{end}}
|
||||
<form class="issue-content ui comment form form-fetch-action" id="new-issue" action="{{.Link}}" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="issue-content-left">
|
||||
@ -9,7 +7,10 @@
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 40}}
|
||||
<div class="ui segment content tw-my-0">
|
||||
<div class="field">
|
||||
<input name="title" class="js-autofocus-end" id="issue_title" placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}" value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}" required maxlength="255" autocomplete="off">
|
||||
<input name="title" data-global-init="initInputAutoFocusEnd" id="issue_title" required maxlength="255" autocomplete="off"
|
||||
placeholder="{{ctx.Locale.Tr "repo.milestones.title"}}"
|
||||
value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}"
|
||||
>
|
||||
{{if .PageIsComparePull}}
|
||||
<div class="title_wip_desc" data-wip-prefixes="{{JsonUtils.EncodeToString .PullRequestWorkInProgressPrefixes}}">{{ctx.Locale.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0)}}</div>
|
||||
{{end}}
|
||||
|
@ -1,6 +0,0 @@
|
||||
export function initAutoFocusEnd() {
|
||||
for (const el of document.querySelectorAll<HTMLInputElement>('.js-autofocus-end')) {
|
||||
el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import {GET} from '../modules/fetch.ts';
|
||||
import {showGlobalErrorMessage} from '../bootstrap.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {observeAddedElement} from '../modules/observer.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
|
||||
const {appUrl} = window.config;
|
||||
|
||||
@ -30,7 +30,7 @@ export function initFootLanguageMenu() {
|
||||
|
||||
export function initGlobalDropdown() {
|
||||
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
|
||||
observeAddedElement('.ui.dropdown:not(.custom)', (el) => {
|
||||
registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
|
||||
const $dropdown = fomanticQuery(el);
|
||||
if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
|
||||
|
||||
@ -80,6 +80,25 @@ export function initGlobalTabularMenu() {
|
||||
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false});
|
||||
}
|
||||
|
||||
// for performance considerations, it only uses performant syntax
|
||||
function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
if (el.type !== 'hidden' &&
|
||||
el.type !== 'checkbox' &&
|
||||
el.type !== 'radio' &&
|
||||
el.type !== 'range' &&
|
||||
el.type !== 'color') {
|
||||
el.dir = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalInput() {
|
||||
registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
|
||||
registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
|
||||
el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
|
||||
* * Cross-origin API request without correct cookie
|
||||
|
@ -10,7 +10,7 @@ import {POST, GET} from '../modules/fetch.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {invertFileFolding} from './file-fold.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {observeAddedElement} from '../modules/observer.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
|
||||
const {i18n} = window.config;
|
||||
|
||||
@ -254,7 +254,7 @@ export function initRepoDiffView() {
|
||||
initExpandAndCollapseFilesButton();
|
||||
initRepoDiffHashChangeListener();
|
||||
|
||||
observeAddedElement('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
|
||||
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
|
||||
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
|
||||
invertFileFolding(el.closest('.file-content'), el);
|
||||
});
|
||||
|
@ -11,7 +11,6 @@ import {initImageDiff} from './features/imagediff.ts';
|
||||
import {initRepoMigration} from './features/repo-migration.ts';
|
||||
import {initRepoProject} from './features/repo-projects.ts';
|
||||
import {initTableSort} from './features/tablesort.ts';
|
||||
import {initAutoFocusEnd} from './features/autofocus-end.ts';
|
||||
import {initAdminUserListSearchForm} from './features/admin/users.ts';
|
||||
import {initAdminConfigs} from './features/admin/config.ts';
|
||||
import {initMarkupAnchors} from './markup/anchors.ts';
|
||||
@ -62,62 +61,23 @@ import {initRepoContributors} from './features/contributors.ts';
|
||||
import {initRepoCodeFrequency} from './features/code-frequency.ts';
|
||||
import {initRepoRecentCommits} from './features/recent-commits.ts';
|
||||
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
|
||||
import {initAddedElementObserver} from './modules/observer.ts';
|
||||
import {initGlobalSelectorObserver} from './modules/observer.ts';
|
||||
import {initRepositorySearch} from './features/repo-search.ts';
|
||||
import {initColorPickers} from './features/colorpicker.ts';
|
||||
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
|
||||
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
|
||||
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
|
||||
import {
|
||||
initFootLanguageMenu,
|
||||
initGlobalDropdown,
|
||||
initGlobalTabularMenu,
|
||||
initHeadNavbarContentToggle,
|
||||
} from './features/common-page.ts';
|
||||
import {
|
||||
initGlobalButtonClickOnEnter,
|
||||
initGlobalButtons,
|
||||
initGlobalDeleteButton,
|
||||
} from './features/common-button.ts';
|
||||
import {
|
||||
initGlobalComboMarkdownEditor,
|
||||
initGlobalEnterQuickSubmit,
|
||||
initGlobalFormDirtyLeaveConfirm,
|
||||
} from './features/common-form.ts';
|
||||
import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts';
|
||||
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
|
||||
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
||||
import {callInitFunctions} from './modules/init.ts';
|
||||
|
||||
initGiteaFomantic();
|
||||
initAddedElementObserver();
|
||||
initSubmitEventPolyfill();
|
||||
|
||||
function callInitFunctions(functions: (() => any)[]) {
|
||||
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
|
||||
// It is a quick check, no side effect so no need to do slow URL parsing.
|
||||
const initStart = performance.now();
|
||||
if (window.location.search.includes('_ui_performance_trace=1')) {
|
||||
let results: {name: string, dur: number}[] = [];
|
||||
for (const func of functions) {
|
||||
const start = performance.now();
|
||||
func();
|
||||
results.push({name: func.name, dur: performance.now() - start});
|
||||
}
|
||||
results = results.sort((a, b) => b.dur - a.dur);
|
||||
for (let i = 0; i < 20 && i < results.length; i++) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`);
|
||||
}
|
||||
} else {
|
||||
for (const func of functions) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
const initDur = performance.now() - initStart;
|
||||
if (initDur > 500) {
|
||||
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
onDomReady(() => {
|
||||
callInitFunctions([
|
||||
const initStartTime = performance.now();
|
||||
const initPerformanceTracer = callInitFunctions([
|
||||
initGlobalDropdown,
|
||||
initGlobalTabularMenu,
|
||||
initGlobalFetchAction,
|
||||
@ -129,6 +89,7 @@ onDomReady(() => {
|
||||
initGlobalFormDirtyLeaveConfirm,
|
||||
initGlobalComboMarkdownEditor,
|
||||
initGlobalDeleteButton,
|
||||
initGlobalInput,
|
||||
|
||||
initCommonOrganization,
|
||||
initCommonIssueListQuickGoto,
|
||||
@ -150,7 +111,6 @@ onDomReady(() => {
|
||||
initSshKeyFormParser,
|
||||
initStopwatch,
|
||||
initTableSort,
|
||||
initAutoFocusEnd,
|
||||
initFindFileInRepo,
|
||||
initCopyContent,
|
||||
|
||||
@ -212,4 +172,13 @@ onDomReady(() => {
|
||||
|
||||
initOAuth2SettingsDisableCheckbox,
|
||||
]);
|
||||
|
||||
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
|
||||
initGlobalSelectorObserver(initPerformanceTracer);
|
||||
if (initPerformanceTracer) initPerformanceTracer.printResults();
|
||||
|
||||
const initDur = performance.now() - initStartTime;
|
||||
if (initDur > 500) {
|
||||
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
|
||||
}
|
||||
});
|
||||
|
26
web_src/js/modules/init.ts
Normal file
26
web_src/js/modules/init.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export class InitPerformanceTracer {
|
||||
results: {name: string, dur: number}[] = [];
|
||||
recordCall(name: string, func: ()=>void) {
|
||||
const start = performance.now();
|
||||
func();
|
||||
this.results.push({name, dur: performance.now() - start});
|
||||
}
|
||||
printResults() {
|
||||
this.results = this.results.sort((a, b) => b.dur - a.dur);
|
||||
for (let i = 0; i < 20 && i < this.results.length; i++) {
|
||||
console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
|
||||
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
|
||||
// It is a quick check, no side effect so no need to do slow URL parsing.
|
||||
const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
|
||||
if (perfTracer) {
|
||||
for (const func of functions) perfTracer.recordCall(func.name, func);
|
||||
} else {
|
||||
for (const func of functions) func();
|
||||
}
|
||||
return perfTracer;
|
||||
}
|
@ -1,52 +1,73 @@
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Promisable} from 'type-fest';
|
||||
import type {InitPerformanceTracer} from './init.ts';
|
||||
|
||||
type DirElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
let globalSelectorObserverInited = false;
|
||||
|
||||
// for performance considerations, it only uses performant syntax
|
||||
function attachDirAuto(el: Partial<DirElement>) {
|
||||
if (el.type !== 'hidden' &&
|
||||
el.type !== 'checkbox' &&
|
||||
el.type !== 'radio' &&
|
||||
el.type !== 'range' &&
|
||||
el.type !== 'color') {
|
||||
el.dir = 'auto';
|
||||
}
|
||||
}
|
||||
type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
|
||||
const selectorHandlers: SelectorHandler[] = [];
|
||||
|
||||
type GlobalInitFunc<T extends HTMLElement> = (el: T) => void | Promise<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
|
||||
function attachGlobalInit(el: HTMLElement) {
|
||||
const initFunc = el.getAttribute('data-global-init');
|
||||
const func = globalInitFuncs[initFunc];
|
||||
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
|
||||
func(el);
|
||||
}
|
||||
|
||||
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => (void | Promise<void>);
|
||||
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
|
||||
const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
|
||||
|
||||
type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
|
||||
|
||||
// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
|
||||
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
|
||||
globalEventFuncs[`${event}:${name}`] = func as any;
|
||||
globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc<HTMLElement, Event>;
|
||||
}
|
||||
|
||||
type SelectorHandler = {
|
||||
selector: string,
|
||||
handler: (el: HTMLElement) => void,
|
||||
};
|
||||
|
||||
const selectorHandlers: SelectorHandler[] = [
|
||||
{selector: 'input, textarea', handler: attachDirAuto},
|
||||
{selector: '[data-global-init]', handler: attachGlobalInit},
|
||||
];
|
||||
|
||||
export function observeAddedElement(selector: string, handler: (el: HTMLElement) => void) {
|
||||
// It handles the global init functions by a selector, for example:
|
||||
// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) });
|
||||
export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
|
||||
selectorHandlers.push({selector, handler});
|
||||
const docNodes = document.querySelectorAll<HTMLElement>(selector);
|
||||
for (const el of docNodes) {
|
||||
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
|
||||
// This approach makes the init stage only need to do one "querySelectorAll".
|
||||
if (!globalSelectorObserverInited) return;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
|
||||
export function initAddedElementObserver(): void {
|
||||
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
|
||||
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
|
||||
globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
|
||||
// The "global init" functions are managed internally and called by callGlobalInitFunc
|
||||
// They must be ready before initGlobalSelectorObserver is called.
|
||||
if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
|
||||
}
|
||||
|
||||
function callGlobalInitFunc(el: HTMLElement) {
|
||||
const initFunc = el.getAttribute('data-global-init');
|
||||
const func = globalInitFuncs[initFunc];
|
||||
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
|
||||
|
||||
type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
|
||||
if ((el as GiteaGlobalInitElement)._giteaGlobalInited) throw new Error(`Global init function "${initFunc}" already executed`);
|
||||
(el as GiteaGlobalInitElement)._giteaGlobalInited = true;
|
||||
func(el);
|
||||
}
|
||||
|
||||
function attachGlobalEvents() {
|
||||
// add global "[data-global-click]" event handler
|
||||
document.addEventListener('click', (e) => {
|
||||
const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
|
||||
if (!elem) return;
|
||||
const funcName = elem.getAttribute('data-global-click');
|
||||
const func = globalEventFuncs[`click:${funcName}`];
|
||||
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
|
||||
func(elem, e);
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void {
|
||||
if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called');
|
||||
globalSelectorObserverInited = true;
|
||||
|
||||
attachGlobalEvents();
|
||||
|
||||
selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
|
||||
const observer = new MutationObserver((mutationList) => {
|
||||
const len = mutationList.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
@ -60,30 +81,27 @@ export function initAddedElementObserver(): void {
|
||||
if (addedNode.matches(selector)) {
|
||||
handler(addedNode);
|
||||
}
|
||||
const children = addedNode.querySelectorAll<HTMLElement>(selector);
|
||||
for (const el of children) {
|
||||
for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
const docNodes = document.querySelectorAll<HTMLElement>(selector);
|
||||
for (const el of docNodes) {
|
||||
handler(el);
|
||||
if (perfTracer) {
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observer.observe(document, {subtree: true, childList: true});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
|
||||
if (!elem) return;
|
||||
const funcName = elem.getAttribute('data-global-click');
|
||||
const func = globalEventFuncs[`click:${funcName}`];
|
||||
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
|
||||
func(elem, e);
|
||||
});
|
||||
}
|
||||
|
@ -355,7 +355,7 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
|
||||
return candidates.length ? candidates[0] as T : null;
|
||||
}
|
||||
|
||||
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
|
||||
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) {
|
||||
parent.addEventListener(type, (e: Event) => {
|
||||
const elem = (e.target as HTMLElement).closest(selector);
|
||||
if (!elem) return;
|
||||
|
Loading…
x
Reference in New Issue
Block a user