2023-05-10 23:50:58 +08:00
import { debounce } from 'throttle-debounce' ;
2025-09-15 16:34:54 +02:00
import type { Promisable } from '../types.ts' ;
2024-08-10 11:46:48 +02:00
import type $ from 'jquery' ;
2024-11-28 01:40:32 +08:00
import { isInFrontendUnitTest } from './testhelper.ts' ;
2023-05-10 23:50:58 +08:00
2024-11-26 09:24:56 +08:00
type ArrayLikeIterable < T > = ArrayLike < T > & Iterable < T > ; // for NodeListOf and Array
type ElementArg = Element | string | ArrayLikeIterable < Element > | ReturnType < typeof $ > ;
2024-11-10 16:26:42 +08:00
type ElementsCallback < T extends Element > = ( el : T ) = > Promisable < any > ;
2024-08-10 11:46:48 +02:00
type ElementsCallbackWithArgs = ( el : Element , . . . args : any [ ] ) = > Promisable < any > ;
2025-05-09 02:26:18 +08:00
function elementsCall ( el : ElementArg , func : ElementsCallbackWithArgs , . . . args : any [ ] ) : ArrayLikeIterable < Element > {
2023-02-22 01:09:03 +08:00
if ( typeof el === 'string' || el instanceof String ) {
2024-08-10 11:46:48 +02:00
el = document . querySelectorAll ( el as string ) ;
2023-02-19 12:06:14 +08:00
}
if ( el instanceof Node ) {
func ( el , . . . args ) ;
2025-05-09 02:26:18 +08:00
return [ el ] ;
2023-02-19 12:06:14 +08:00
} else if ( el . length !== undefined ) {
// this works for: NodeList, HTMLCollection, Array, jQuery
2025-05-09 02:26:18 +08:00
const elems = el as ArrayLikeIterable < Element > ;
for ( const elem of elems ) func ( elem , . . . args ) ;
return elems ;
2023-02-19 12:06:14 +08:00
}
2025-05-09 02:26:18 +08:00
throw new Error ( 'invalid argument to be shown/hidden' ) ;
2023-02-19 12:06:14 +08:00
}
2025-07-10 00:46:51 +08:00
export function toggleElemClass ( el : ElementArg , className : string , force? : boolean ) : ArrayLikeIterable < Element > {
2025-05-09 02:26:18 +08:00
return elementsCall ( el , ( e : Element ) = > {
2025-03-30 14:19:54 +08:00
if ( force === true ) {
e . classList . add ( className ) ;
} else if ( force === false ) {
e . classList . remove ( className ) ;
} else if ( force === undefined ) {
e . classList . toggle ( className ) ;
} else {
throw new Error ( 'invalid force argument' ) ;
}
} ) ;
}
2023-02-22 01:09:03 +08:00
/**
2025-03-30 14:19:54 +08:00
* @param el ElementArg
2023-02-22 01:09:03 +08:00
* @param force force=true to show or force=false to hide, undefined to toggle
*/
2025-05-09 02:26:18 +08:00
export function toggleElem ( el : ElementArg , force? : boolean ) : ArrayLikeIterable < Element > {
2025-07-10 00:46:51 +08:00
return toggleElemClass ( el , 'tw-hidden' , force === undefined ? force : ! force ) ;
2023-02-19 12:06:14 +08:00
}
2025-05-09 02:26:18 +08:00
export function showElem ( el : ElementArg ) : ArrayLikeIterable < Element > {
return toggleElem ( el , true ) ;
2023-02-19 12:06:14 +08:00
}
2023-04-02 00:40:22 +02:00
2025-05-09 02:26:18 +08:00
export function hideElem ( el : ElementArg ) : ArrayLikeIterable < Element > {
return toggleElem ( el , false ) ;
2023-05-10 23:50:58 +08:00
}
2024-11-10 16:26:42 +08:00
function applyElemsCallback < T extends Element > ( elems : ArrayLikeIterable < T > , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-03-31 18:39:50 +03:00
if ( fn ) {
for ( const el of elems ) {
fn ( el ) ;
}
}
return elems ;
}
2024-11-10 16:26:42 +08:00
export function queryElemSiblings < T extends Element > ( el : Element , selector = '*' , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2025-12-03 03:13:16 +01:00
if ( ! el . parentNode ) return [ ] ;
2024-11-08 14:04:24 +08:00
const elems = Array . from ( el . parentNode . children ) as T [ ] ;
return applyElemsCallback < T > ( elems . filter ( ( child : Element ) = > {
2024-08-10 11:46:48 +02:00
return child !== el && child . matches ( selector ) ;
} ) , fn ) ;
2024-03-31 18:39:50 +03:00
}
2025-08-01 00:59:34 +02:00
/** it works like jQuery.children: only the direct children are selected */
2024-11-10 16:26:42 +08:00
export function queryElemChildren < T extends Element > ( parent : Element | ParentNode , selector = '*' , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-11-28 01:40:32 +08:00
if ( isInFrontendUnitTest ( ) ) {
// https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work
2024-11-26 23:10:45 +08:00
const selected = Array . from < T > ( parent . children as any ) . filter ( ( child ) = > child . matches ( selector ) ) ;
return applyElemsCallback < T > ( selected , fn ) ;
}
2024-11-08 14:04:24 +08:00
return applyElemsCallback < T > ( parent . querySelectorAll ( ` :scope > ${ selector } ` ) , fn ) ;
2024-02-24 21:11:51 +02:00
}
2025-08-01 00:59:34 +02:00
/** it works like parent.querySelectorAll: all descendants are selected */
2025-04-19 08:17:07 +08:00
// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent if the targets are not for page-level components.
2024-12-21 19:59:25 +01:00
export function queryElems < T extends HTMLElement > ( parent : Element | ParentNode , selector : string , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-11-08 14:04:24 +08:00
return applyElemsCallback < T > ( parent . querySelectorAll ( selector ) , fn ) ;
2024-04-19 00:45:50 +08:00
}
2024-08-10 11:46:48 +02:00
export function onDomReady ( cb : ( ) = > Promisable < void > ) {
2023-04-02 00:40:22 +02:00
if ( document . readyState === 'loading' ) {
document . addEventListener ( 'DOMContentLoaded' , cb ) ;
} else {
cb ( ) ;
}
}
2023-04-08 01:03:29 +08:00
2025-08-01 00:59:34 +02:00
/** checks whether an element is owned by the current document, and whether it is a document fragment or element node
* if it is, it means it is a "normal" element managed by us, which can be modified safely. */
2024-11-08 14:04:24 +08:00
export function isDocumentFragmentOrElementNode ( el : Node ) {
2024-02-08 10:42:18 +08:00
try {
return el . ownerDocument === document && el . nodeType === Node . ELEMENT_NODE || el . nodeType === Node . DOCUMENT_FRAGMENT_NODE ;
} catch {
// in case the el is not in the same origin, then the access to nodeType would fail
return false ;
}
}
2025-08-01 00:59:34 +02:00
/** autosize a textarea to fit content. */
// Based on https://github.com/github/textarea-autosize
2023-04-08 01:03:29 +08:00
// ---------------------------------------------------------------------
// Copyright (c) 2018 GitHub, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// ---------------------------------------------------------------------
2024-08-10 11:46:48 +02:00
export function autosize ( textarea : HTMLTextAreaElement , { viewportMarginBottom = 0 } : { viewportMarginBottom? : number } = { } ) {
2023-04-08 01:03:29 +08:00
let isUserResized = false ;
// lastStyleHeight and initialStyleHeight are CSS values like '100px'
2025-12-03 03:13:16 +01:00
let lastMouseX : number | undefined ;
let lastMouseY : number | undefined ;
let lastStyleHeight : string | undefined ;
let initialStyleHeight : string | undefined ;
2023-04-08 01:03:29 +08:00
2024-08-10 11:46:48 +02:00
function onUserResize ( event : MouseEvent ) {
2023-04-08 01:03:29 +08:00
if ( isUserResized ) return ;
if ( lastMouseX !== event . clientX || lastMouseY !== event . clientY ) {
const newStyleHeight = textarea . style . height ;
if ( lastStyleHeight && lastStyleHeight !== newStyleHeight ) {
isUserResized = true ;
}
lastStyleHeight = newStyleHeight ;
}
lastMouseX = event . clientX ;
lastMouseY = event . clientY ;
}
function overflowOffset() {
let offsetTop = 0 ;
let el = textarea ;
while ( el !== document . body && el !== null ) {
offsetTop += el . offsetTop || 0 ;
2024-08-10 11:46:48 +02:00
el = el . offsetParent as HTMLTextAreaElement ;
2023-04-08 01:03:29 +08:00
}
2025-12-03 03:13:16 +01:00
const scrollY = document . defaultView ? document . defaultView.scrollY : 0 ;
const top = offsetTop - scrollY ;
2023-04-08 01:03:29 +08:00
const bottom = document . documentElement . clientHeight - ( top + textarea . offsetHeight ) ;
return { top , bottom } ;
}
function resizeToFit() {
if ( isUserResized ) return ;
if ( textarea . offsetWidth <= 0 && textarea . offsetHeight <= 0 ) return ;
2025-05-13 08:52:25 +02:00
const previousMargin = textarea . style . marginBottom ;
2023-04-08 01:03:29 +08:00
try {
const { top , bottom } = overflowOffset ( ) ;
const isOutOfViewport = top < 0 || bottom < 0 ;
const computedStyle = getComputedStyle ( textarea ) ;
const topBorderWidth = parseFloat ( computedStyle . borderTopWidth ) ;
const bottomBorderWidth = parseFloat ( computedStyle . borderBottomWidth ) ;
const isBorderBox = computedStyle . boxSizing === 'border-box' ;
const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0 ;
2024-10-31 05:19:15 +01:00
const adjustedViewportMarginBottom = Math . min ( bottom , viewportMarginBottom ) ;
2023-04-08 01:03:29 +08:00
const curHeight = parseFloat ( computedStyle . height ) ;
const maxHeight = curHeight + bottom - adjustedViewportMarginBottom ;
2025-05-13 08:52:25 +02:00
// In Firefox, setting auto height momentarily may cause the page to scroll up
// unexpectedly, prevent this by setting a temporary margin.
textarea . style . marginBottom = ` ${ textarea . clientHeight } px ` ;
2023-04-08 01:03:29 +08:00
textarea . style . height = 'auto' ;
let newHeight = textarea . scrollHeight + borderAddOn ;
if ( isOutOfViewport ) {
// it is already out of the viewport:
// * if the textarea is expanding: do not resize it
if ( newHeight > curHeight ) {
newHeight = curHeight ;
}
// * if the textarea is shrinking, shrink line by line (just use the
// scrollHeight). do not apply max-height limit, otherwise the page
// flickers and the textarea jumps
} else {
// * if it is in the viewport, apply the max-height limit
newHeight = Math . min ( maxHeight , newHeight ) ;
}
textarea . style . height = ` ${ newHeight } px ` ;
lastStyleHeight = textarea . style . height ;
} finally {
2025-05-13 08:52:25 +02:00
// restore previous margin
if ( previousMargin ) {
textarea . style . marginBottom = previousMargin ;
} else {
textarea . style . removeProperty ( 'margin-bottom' ) ;
}
2023-04-08 01:03:29 +08:00
// ensure that the textarea is fully scrolled to the end, when the cursor
// is at the end during an input event
if ( textarea . selectionStart === textarea . selectionEnd &&
textarea . selectionStart === textarea . value . length ) {
textarea . scrollTop = textarea . scrollHeight ;
}
}
}
function onFormReset() {
isUserResized = false ;
if ( initialStyleHeight !== undefined ) {
textarea . style . height = initialStyleHeight ;
} else {
textarea . style . removeProperty ( 'height' ) ;
}
}
textarea . addEventListener ( 'mousemove' , onUserResize ) ;
textarea . addEventListener ( 'input' , resizeToFit ) ;
textarea . form ? . addEventListener ( 'reset' , onFormReset ) ;
initialStyleHeight = textarea . style . height ? ? undefined ;
if ( textarea . value ) resizeToFit ( ) ;
return {
resizeToFit ,
destroy() {
textarea . removeEventListener ( 'mousemove' , onUserResize ) ;
textarea . removeEventListener ( 'input' , resizeToFit ) ;
textarea . form ? . removeEventListener ( 'reset' , onFormReset ) ;
2024-03-22 15:06:53 +01:00
} ,
2023-04-08 01:03:29 +08:00
} ;
}
2023-05-10 23:50:58 +08:00
2024-08-10 11:46:48 +02:00
export function onInputDebounce ( fn : ( ) = > Promisable < any > ) {
2023-05-10 23:50:58 +08:00
return debounce ( 300 , fn ) ;
}
2023-10-11 14:34:21 +02:00
2024-08-10 11:46:48 +02:00
type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement ;
2025-08-01 00:59:34 +02:00
/** Set the `src` attribute on an element and returns a promise that resolves once the element
* has loaded or errored. */
2024-08-10 11:46:48 +02:00
export function loadElem ( el : LoadableElement , src : string ) {
2023-10-11 14:34:21 +02:00
return new Promise ( ( resolve ) = > {
el . addEventListener ( 'load' , ( ) = > resolve ( true ) , { once : true } ) ;
el . addEventListener ( 'error' , ( ) = > resolve ( false ) , { once : true } ) ;
el . src = src ;
} ) ;
}
2023-12-15 07:26:36 +08:00
// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter
// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)"
const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined' ;
2025-01-22 08:11:51 +01:00
export function submitEventSubmitter ( e : any ) {
2024-02-18 04:48:10 +08:00
e = e . originalEvent ? ? e ; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself
2023-12-15 07:26:36 +08:00
return needSubmitEventPolyfill ? ( e . target . _submitter || null ) : e . submitter ;
}
2025-12-03 03:13:16 +01:00
function submitEventPolyfillListener ( e : Event ) {
const form = ( e . target as HTMLElement ) . closest ( 'form' ) ;
2023-12-15 07:26:36 +08:00
if ( ! form ) return ;
2025-12-03 03:13:16 +01:00
form . _submitter = ( e . target as HTMLElement ) . closest ( 'button:not([type]), button[type="submit"], input[type="submit"]' ) ;
2023-12-15 07:26:36 +08:00
}
export function initSubmitEventPolyfill() {
if ( ! needSubmitEventPolyfill ) return ;
console . warn ( ` This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill ` ) ;
document . body . addEventListener ( 'click' , submitEventPolyfillListener ) ;
document . body . addEventListener ( 'focus' , submitEventPolyfillListener ) ;
}
2024-02-20 12:37:37 +02:00
2025-05-09 02:26:18 +08:00
export function isElemVisible ( el : HTMLElement ) : boolean {
// Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
// This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem"
if ( ! el ) return false ;
// checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
2025-12-03 03:13:16 +01:00
return Boolean ( ! el . classList . contains ( 'tw-hidden' ) && ( el . offsetWidth || el . offsetHeight || el . getClientRects ( ) . length ) && el . style . display !== 'none' ) ;
2024-02-20 12:37:37 +02:00
}
2024-03-08 16:15:58 +01:00
2026-04-19 23:48:33 +08:00
export function createElementFromHTML < T extends Element > ( htmlString : string ) : T {
2024-11-21 22:09:16 +08:00
htmlString = htmlString . trim ( ) ;
2025-07-05 23:21:53 +08:00
// There is no way to create some elements without a proper parent, jQuery's approach: https://github.com/jquery/jquery/blob/main/src/manipulation/wrapMap.js
// eslint-disable-next-line github/unescaped-html-literal
2024-11-21 22:09:16 +08:00
if ( htmlString . startsWith ( '<tr' ) ) {
const container = document . createElement ( 'table' ) ;
container . innerHTML = htmlString ;
2025-12-03 03:13:16 +01:00
return container . querySelector < T > ( 'tr' ) ! ;
2024-11-21 22:09:16 +08:00
}
2024-06-07 15:42:31 +02:00
const div = document . createElement ( 'div' ) ;
2024-11-21 22:09:16 +08:00
div . innerHTML = htmlString ;
return div . firstChild as T ;
2024-06-07 15:42:31 +02:00
}
2024-06-27 01:01:20 +08:00
2026-02-11 11:22:33 +08:00
export function createElementFromAttrs < T extends HTMLElement > ( tagName : string , attrs : Record < string , any > | null , . . . children : ( Node | string ) [ ] ) : T {
2024-06-27 01:01:20 +08:00
const el = document . createElement ( tagName ) ;
2024-10-31 04:06:36 +08:00
for ( const [ key , value ] of Object . entries ( attrs || { } ) ) {
2024-06-27 01:01:20 +08:00
if ( value === undefined || value === null ) continue ;
2024-08-02 03:06:03 +08:00
if ( typeof value === 'boolean' ) {
2024-06-27 01:01:20 +08:00
el . toggleAttribute ( key , value ) ;
} else {
el . setAttribute ( key , String ( value ) ) ;
}
2024-10-31 04:06:36 +08:00
}
for ( const child of children ) {
el . append ( child instanceof Node ? child : document.createTextNode ( child ) ) ;
2024-06-27 01:01:20 +08:00
}
2026-02-11 11:22:33 +08:00
return el as T ;
2024-06-27 01:01:20 +08:00
}
2024-06-27 21:58:38 +08:00
2024-08-10 11:46:48 +02:00
export function animateOnce ( el : Element , animationClassName : string ) : Promise < void > {
2024-06-27 21:58:38 +08:00
return new Promise ( ( resolve ) = > {
el . addEventListener ( 'animationend' , function onAnimationEnd() {
el . classList . remove ( animationClassName ) ;
el . removeEventListener ( 'animationend' , onAnimationEnd ) ;
resolve ( ) ;
} , { once : true } ) ;
el . classList . add ( animationClassName ) ;
} ) ;
}
2024-11-07 04:21:53 +08:00
export function querySingleVisibleElem < T extends HTMLElement > ( parent : Element , selector : string ) : T | null {
const elems = parent . querySelectorAll < HTMLElement > ( selector ) ;
const candidates = Array . from ( elems ) . filter ( isElemVisible ) ;
if ( candidates . length > 1 ) throw new Error ( ` Expected exactly one visible element matching selector " ${ selector } ", but found ${ candidates . length } ` ) ;
return candidates . length ? candidates [ 0 ] as T : null ;
}
2024-11-21 22:09:16 +08:00
2025-03-03 10:57:28 +08:00
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 ) {
2024-11-21 22:09:16 +08:00
parent . addEventListener ( type , ( e : Event ) = > {
const elem = ( e . target as HTMLElement ) . closest ( selector ) ;
2025-04-19 16:43:22 +08:00
// It strictly checks "parent contains the target elem" to avoid side effects of selector running on outside the parent.
// Keep in mind that the elem could have been removed from parent by other event handlers before this event handler is called.
2025-07-05 23:21:53 +08:00
// For example, tippy popup item, the tippy popup could be hidden and removed from DOM before this.
// It is the caller's responsibility to make sure the elem is still in parent's DOM when this event handler is called.
2025-04-19 16:43:22 +08:00
if ( ! elem || ( parent !== document && ! parent . contains ( elem ) ) ) return ;
2024-11-28 01:40:32 +08:00
listener ( elem as T , e as E ) ;
2024-11-21 22:09:16 +08:00
} , options ) ;
}
2025-06-19 20:28:19 +02:00
2025-08-01 00:59:34 +02:00
/** Returns whether a click event is a left-click without any modifiers held */
2025-06-19 20:28:19 +02:00
export function isPlainClick ( e : MouseEvent ) {
return e . button === 0 && ! e . ctrlKey && ! e . metaKey && ! e . altKey && ! e . shiftKey ;
}
2025-07-05 23:21:53 +08:00
let elemIdCounter = 0 ;
export function generateElemId ( prefix : string = '' ) : string {
return ` ${ prefix } ${ elemIdCounter ++ } ` ;
}