/** @module dom */
import { transformDashToCamelCase, transformCamelCaseToDash, stringToNumber, isArray, isString, isObject, isFunction, shallowMerge, percentage, clamp, sampleVelocity } from './helpers.mjs'
import { encodeHtmlEntities, decodeHtmlEntities } from './entities.mjs'
/**
* Checks if an element is empty
*
* @param {HTMLElement} element
* @returns boolean
* @example
* document.body.innerHTML = `
* <div id="empty-element"></div>
* <div id="non-empty-element1">foo</div>
* <div id="non-empty-element2"><br></div>`
*
* isEmptyElement(document.getElementById('empty-element')) // => true
* isEmptyElement(document.getElementById('non-empty-element1')) // => false
* isEmptyElement(document.getElementById('non-empty-element2')) // => false
*/
export function isEmptyElement(element) {
return element.innerHTML.trim() === ''
}
/**
* Removes all elements matching a selector from the DOM
*
* @param {string|HTMLElement|Element} selector The selector to select elements to remove
* @param {HTMLElement|Element} [from=document] The element to remove elements from
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
* `
* remove('#foo, #bar') // => removes #foo and #bar
*/
export function remove(selector, from = document) {
const elements = query(selector, from)
for (const element of elements) {
element.remove()
}
}
/**
* Queries the DOM for a single element and returns it. Substitutes for `document.querySelector(selector)` and JQuery's `$(selector).first()`
*
* @param {string|HTMLElement|Element|Array<HTMLElement|Element>|NodeList} selector The selector to select an element
* @param {HTMLElement|Element} [from=document] The element to query from
* @returns {HTMLElement|Element}
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* querySingle('#foo') // => <div id="foo"></div>
* querySingle(document.getElementById('foo')) // => <div id="foo"></div>
* querySingle(document.querySelector('#foo')) // => <div id="foo"></div>
*/
export function querySingle(selector, from = document) {
if (selector instanceof Element) return selector
return from.querySelector(selector)
}
/**
* Queries the DOM for elements and returns them. Substitutes for `document.querySelectorAll(selector)` and JQuery's `$(selector)`
*
* @param {string|HTMLElement|Element|Array<HTMLElement|Element>|NodeList} selector The selector to select elements
* @param {HTMLElement|Element} [from=document] The element to query from
* @returns {Array<Element>|NodeList}
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* query('#foo') // => [<div id="foo"></div>]
* query(document.getElementById('foo')) // => [<div id="foo"></div>]
* query('div') // => [<div id="foo"></div>, <div id="bar"></div>, <div id="baz"></div>]
*/
export function query(selector, from = document) {
if (selector instanceof Array || selector instanceof NodeList) return selector
if (selector instanceof Element) return [selector]
if (from instanceof Element || from instanceof Document) return from.querySelectorAll(selector)
if (isString(from)) from = query(from)
if (!(from instanceof Array || from instanceof NodeList)) return []
const res = []
for (const element of from) {
res.push(...element.querySelectorAll(selector))
}
return res
}
/**
* Sets element styles from passed object of styles. Can also transform dash-case to camelCase for CSS properties
*
* @param {HTMLElement} element The element to set styles on
* @param {object} styles The object of styles to set
* @param {boolean} transform Whether to transform dash-case to camelCase for CSS properties
* @example
* css(document.getElementById('foo'), { 'background-color': 'red', 'font-size': '16px' }, true) // => sets background-color and font-size
* css(document.getElementById('foo'), { backgroundColor: 'red', fontSize: '16px' }) // => sets background-color and font-size
*/
export function css(element, styles, transform = false) {
if (!element || !styles) return
for (let property in styles) {
const key = transform ? transformDashToCamelCase(property) : property
element.style[key] = styles[property]
}
}
/**
* Decodes HTML entities in a string using the browser's DOMParser. If the DOMParser is not available, it uses a regular expression to decode the basic entities.
*
* @see {@link module:parsers.decodeHtmlEntities}
*
* @param {string} html The HTML string to decode
* @returns {string} The decoded HTML string
* @example
* decodeHTML('<div>foo</div>') // => '<div>foo</div>'
* decodeHTML('<div>foo</div><div>bar</div>') // => '<div>foo</div><div>bar</div>'
*/
export function decodeHTML(html) {
if (typeof document === 'undefined') return decodeHtmlEntities(html)
const txt = document.createElement('textarea')
txt.innerHTML = html
const res = txt.value
txt.remove()
return res
}
/**
* Encodes HTML entities in a string using the browser's DOMParser. If the DOMParser is not available, it uses a regular expression to encode the basic entities.
*
* @see {@link module:parsers.encodeHtmlEntities}
*
* @param {string} html The HTML string to encode
* @returns {string} The encoded HTML string
* @example
* encodeHTML('<div>foo</div>') // => '<div>foo</div>'
* encodeHTML('<div>foo</div><div>bar</div>') // => '<div>foo</div><div>bar</div>'
*/
export function encodeHTML(html) {
if (typeof document === 'undefined') return encodeHtmlEntities(html)
const txt = document.createElement('textarea')
txt.textContent = html
const res = txt.innerHTML
txt.remove()
return res
}
/**
* Inserts an element before another element
*
* @param {HTMLElement} targetElement The element to insert before
* @param {HTMLElement} newElement The element to insert
* @example
* const target = document.getElementById('target')
* const newElement = document.createElement('div')
* newElement.id = 'newElement'
* insertBeforeElement(target, newElement)
* // <div id="newElement"></div>
* // <div id="target"></div>
*/
export function insertBeforeElement(targetElement, newElement) {
if (!targetElement || !newElement) return
targetElement.parentNode.insertBefore(newElement, targetElement);
}
/**
* Toggles an attribute value on an element
*
* @param {HTMLElement} element The element to toggle the attribute on
* @param {string} attribute The attribute to toggle
* @param {string} on Default: 'true'
* @param {string} off Default: 'false'
* @example
* toggleAttributeValue(element, 'aria-expanded', 'true', 'false')
* toggleAttributeValue(element, 'aria-expanded')
*/
export function toggleAttributeValue(element, attribute, on = 'true', off = 'false') {
if (!element.hasAttribute(attribute)) return
if (element.getAttribute(attribute) === on) {
element.setAttribute(attribute, off)
} else {
element.setAttribute(attribute, on)
}
}
/**
* Converts a duration string to milliseconds integer
*
* @param {string} duration The duration string to convert, e.g. '1s', '100ms', '0.5s'
* @returns {number} The duration in milliseconds
* @example
* convertToMilliseconds('1s') // 1000
* convertToMilliseconds('100ms') // 100
* convertToMilliseconds('0.5s') // 500
* convertToMilliseconds('0.5') // 0
* convertToMilliseconds('foo') // 0
*/
export function cssTimeToMilliseconds(duration) {
const regExp = new RegExp('([0-9.]+)([a-z]+)', 'i')
const matches = regExp.exec(duration)
if (!matches) return 0
const unit = matches[2]
switch (unit) {
case 'ms':
return parseFloat(matches[1])
case 's':
return parseFloat(matches[1]) * 1000
default:
return 0
}
}
/**
* Returns a map of transition properties and durations
*
* @param {HTMLElement} element The element to get the transition properties and durations from
* @returns {object<string, number>} A map of transition properties and durations
* @example
* getTransitionDurations(element) // { height: 1000 } if transition in CSS is set to 'height 1s'
* getTransitionDurations(element) // { height: 500, opacity: 1000 } if transition in CSS is set to 'height 0.5s, opacity 1s'
*/
export function getTransitionDurations(element) {
if (!element) return {}
const styles = getComputedStyle(element)
const transitionProperties = styles.getPropertyValue('transition-property').split(',')
const transitionDurations = styles.getPropertyValue('transition-duration').split(',')
const map = {}
for (let i = 0; i < transitionProperties.length; i++) {
const property = transitionProperties[i].trim()
// CSS repeats the duration list when it's shorter than the property list
map[property] = cssTimeToMilliseconds(transitionDurations[i % transitionDurations.length].trim())
}
return map
}
/**
* Read a set of typed options off an element's attributes.
*
* Accepts the bare, kebab-case and `data-*` spellings of each option, with `data-*`
* winning when both are present, so a widget can be configured whichever way the
* markup author reaches for. Only the options actually present come back - absent
* ones are left out rather than defaulted, so the result merges cleanly over
* whatever defaults the caller holds.
*
* Booleans follow HTML, not JavaScript: a bare attribute parses as an empty string
* and means "on", and only `false` and `0` mean off. Numbers that do not parse are
* dropped rather than coming back as NaN.
*
* @param {HTMLElement} element The element to read the attributes from
* @param {object<string, 'boolean'|'number'|'string'>} schema A map of camelCase option names to types
* @returns {object} The options present on the element, typed
* @example
* // <div data-page-step="25" exclusive label="FAQ">
* readOptions(element, { pageStep: 'number', exclusive: 'boolean', label: 'string', muted: 'boolean' })
* // => { pageStep: 25, exclusive: true, label: 'FAQ' }
*/
export function readOptions(element, schema) {
const options = {}
if (!element || !schema) return options
for (const key in schema) {
const raw = element.dataset[key] != null ? element.dataset[key] : element.getAttribute(transformCamelCaseToDash(key))
if (raw == null) continue
if (schema[key] === 'boolean') {
// A bare attribute reads as '', which in HTML means "on"
options[key] = raw !== 'false' && raw !== '0'
} else if (schema[key] === 'number') {
const num = stringToNumber(raw)
if (num !== undefined) options[key] = num
} else {
options[key] = raw
}
}
return options
}
/**
* Returns the duration a single property will actually transition with, in milliseconds.
*
* Unlike reading getTransitionDurations() by key, this resolves the `all` keyword: a
* `transition: all 1s` animates height too, it just isn't listed under that name. An
* untransitioned property reads as 0, which is also what `transition: none` gives you -
* so a reduced motion media query switching the transition off reads as "no animation".
*
* @param {HTMLElement} element The element to get the transition duration from
* @param {string} [property='all'] The CSS property to get the duration for
* @returns {number} The duration in milliseconds, 0 if the property is not transitioned
* @example
* getTransitionDuration(element, 'height') // 1000 if transition in CSS is set to 'height 1s'
* getTransitionDuration(element, 'height') // 300 if transition in CSS is set to 'all 0.3s'
* getTransitionDuration(element, 'height') // 0 if transition in CSS is set to 'opacity 1s'
*/
export function getTransitionDuration(element, property = 'all') {
const durations = getTransitionDurations(element)
if (durations.hasOwnProperty(property)) return durations[property]
if (durations.hasOwnProperty('all')) return durations.all
return 0
}
/**
* Check a list of elements if any of them matches a selector
*
* @param {Array<HTMLElement>|NodeList|HTMLElement} elements The elements to check
* @param {string} selector The selector to check
* @returns {boolean} True if any of the elements matches the selector, false otherwise
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* matchesAny(document.querySelectorAll('div'), '#foo') // => true
* matchesAny(document.querySelectorAll('div'), '#qux') // => false
*/
export function matchesAny(elements, selector) {
if (!elements || !selector) return false
if (elements instanceof Element) elements = [elements]
if (isString(elements)) elements = query(elements)
if (!elements.length) return false
for (const element of elements) {
if (element.matches(selector)) return true
}
return false
}
/**
* Check a list of elements if all of them matches a selector
*
* @param {Array<HTMLElement>|NodeList|HTMLElement} elements The elements to check
* @param {string} selector The selector to check
* @returns {boolean} True if all of the elements matches the selector, false otherwise
* @example
* document.body.innerHTML = `
* <div id="foo"></div>
* <div id="bar"></div>
* <div id="baz"></div>`
*
* matchesAll(document.querySelectorAll('div'), 'div') // => true
* matchesAll(document.querySelectorAll('div'), '#foo') // => false
*/
export function matchesAll(elements, selector) {
if (!elements || !selector) return false
if (elements instanceof Element) elements = [elements]
if (isString(elements)) elements = query(elements)
if (!elements.length) return false
for (const element of elements) {
if (!element.matches(selector)) return false
}
return true
}
/**
* Detaches an element from the DOM and returns it
*
* @param {HTMLElement} element The element to detach
* @example
* detachElement(element)
* // => element
* console.log(element.parentNode) // => null
*/
export function detachElement(element) {
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
return element
}
/**
* Gets table data from a table element, a simple regular table element, or a table like structure.
* Useful for scraping data.
*
* @param {string} selector The selector to select the table element
* @param {Array<string>|string|null} headers The headers to use for the data. If 'auto' is passed, the row containing th or the first row will be used as headers
* @param {string} [rowSelector='tr'] The selector to select the rows
* @param {string} [cellSelector='td'] The selector to select the cells
* @returns {Array<object>} An array of objects with the properties as keys and the cell values as values
* @example
* document.body.innerHTML = `
* <table id="table">
* <thead>
* <tr>
* <th>Foo</th>
* <th>Bar</th>
* </tr>
* </thead>
* <tbody>
* <tr>
* <td>Foo 1</td>
* <td>Bar 1</td>
* </tr>
* <tr>
* <td>Foo 2</td>
* <td>Bar 2</td>
* </tr>
* </tbody>
* </table>`
*
* getTableData('#table', ['foo', 'bar'])
* // => [
* // { foo: 'Foo 1', bar: 'Bar 1' },
* // { foo: 'Foo 2', bar: 'Bar 2' }
* // ]
*/
export function getTableData(selector, headers, rowSelector = 'tr', cellSelector = 'td', headerCellSelector = 'th') {
const table = typeof selector === 'string' ? document.querySelector(selector) : selector
if (!table) return []
const res = []
const rows = table.querySelectorAll(rowSelector)
let start = 0
function iterateHeaders(arr) {
if (!arr || !arr.length) return
const res = []
for (let i = 0; i < arr.length; i++) {
res.push(arr[i].textContent.trim())
}
return res
}
if (headers && isString(headers) && headers === 'auto') {
let headerCells = table.querySelectorAll(headerCellSelector)
if (headerCells && headerCells.length) {
headers = iterateHeaders(headerCells)
} else {
headers = iterateHeaders(rows[0].querySelectorAll(cellSelector))
start = 1
}
}
for (let i = start; i < rows.length; i++) {
const row = rows[i]
const cells = row.querySelectorAll(cellSelector)
if (!cells || !cells.length) continue
let rowData = []
if (headers && isArray(headers) && headers.length) {
rowData = {}
for (let j = 0; j < headers.length; j++) {
rowData[headers[j]] = cells[j] ? cells[j].textContent.trim() : null
}
} else {
for (let j = 0; j < cells.length; j++) {
rowData.push(cells[j].textContent.trim())
}
}
res.push(rowData)
}
return res
}
/**
* Parses HTML string to a DOM Node
*
* @param {string} html The HTML string to parse
* @param {boolean} [allChildren=false] If true, all children of the body will be returned, otherwise only the first child
* @returns {Node} The parsed DOM Node
* @example
* parseDOM('<div>foo</div>') // => <div>foo</div>
* parseDOM('<div>foo</div><div>bar</div>', true) // => NodeList(2) [div, div]
* parseDOM(document.getElementById('foo')) // => <div id="foo"></div>
* parseDOM(document.querySelectorAll('div')) // => NodeList(2) [div, div]
*/
export function parseDOM(html, allChildren) {
if (html instanceof Element || html instanceof NodeList) return html
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
return !allChildren ? doc.body.firstChild : doc.body.childNodes
}
/**
* Loads an image form a provided source url and calls a callback when it's loaded
*
* @param {string} src The source url of the image
* @param {Function} [callback] The callback to call when the image is loaded
* @example
* loadImage('https://example.com/image.png', () => {
* console.log('Image loaded')
* })
*/
export function loadImage(src, callback) {
const img = new Image()
if (callback) img.addEventListener('load', callback, false)
img.src = src
}
const NON_BUBBLING_EVENTS = new Set(['focus', 'blur', 'mouseenter', 'mouseleave', 'load', 'unload', 'scroll', 'resize'])
/**
* Delegate DOM events. Uses event bubbling with closest() for events that bubble.
* Uses MutationObserver for non-bubbling events (focus, blur, mouseenter, mouseleave, etc.)
* to attach listeners directly to matching elements as they appear in the DOM.
*
* @param {string} selector The selector to select the elements to delegate the event to
* @param {string} eventType The event type to delegate, like `click`
* @param {Function} handler The handler to call when the event is triggered.
* @returns {MutationObserver | {destroy: Function, disconnect: Function} | null} For non-bubbling events the MutationObserver instance (with an added destroy() method that also removes the element listeners), for bubbling events a handle with destroy()/disconnect(), or null when MutationObserver is required but unavailable
* @example
* delegateEvent('.foo', 'click', (e, target) => {
* console.log('Clicked on', target)
* })
*/
export function delegateEvent(selector, eventType, handler) {
if (!NON_BUBBLING_EVENTS.has(eventType)) {
const documentListener = (e) => {
const target = e.target.closest(selector)
if (target) handler(e, target)
}
document.addEventListener(eventType, documentListener)
const destroy = () => document.removeEventListener(eventType, documentListener)
return { destroy, disconnect: destroy }
}
if (typeof MutationObserver === 'undefined') return null
const listener = (e) => {
handler(e, e.currentTarget)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue
if (node.matches(selector)) {
node.addEventListener(eventType, listener)
continue
}
for (const child of node.querySelectorAll(selector)) {
child.addEventListener(eventType, listener)
}
}
}
})
for (const node of document.querySelectorAll(selector)) {
node.addEventListener(eventType, listener)
}
observer.observe(document.body, { childList: true, subtree: true })
observer.destroy = () => {
observer.disconnect()
for (const node of document.querySelectorAll(selector)) {
node.removeEventListener(eventType, listener)
}
}
return observer
}
/**
* Run a handler on selected elements and on elements added to the DOM with the same selector as a MutationObserver abstraction,
* or use it to delegate events as a `delegateEvent` alias
*
* @param {string} selector The selector to select the elements to run the handler on
* @param {string | Function} eventTypeOrHandler The event type to delegate, like `click`, or the handler to call on every element
* @param {Function} [handler] The handler to call when the event is triggered.
* @returns {MutationObserver | {destroy: Function, disconnect: Function} | null} The MutationObserver instance (with an added destroy() method), a destroy/disconnect handle for bubbling delegated events, or null when MutationObserver is required but unavailable
* @see delegateEvent
* @example
* on('.foo', (el) => {
* console.log('Element', el, 'added to the DOM')
* })
*
* on('.foo', 'click', (e, target) => {
* console.log('Clicked on', target)
* })
*/
export function on(selector, eventTypeOrHandler, handler) {
if (isString(eventTypeOrHandler)) {
return delegateEvent(selector, eventTypeOrHandler, handler)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue
if (node.matches(selector)) {
eventTypeOrHandler(node)
continue
}
for (const child of node.querySelectorAll(selector)) {
eventTypeOrHandler(child)
}
}
}
})
for (const node of document.querySelectorAll(selector)) {
eventTypeOrHandler(node)
}
observer.observe(document.body, { childList: true, subtree: true })
observer.destroy = () => observer.disconnect()
return observer
}
/**
* Adds one listener to multiple events
*
* @param {string|HTMLElement|NodeList} elements The elements or a selector for elements to add the event listeners to
* @param {string|Array<string>} events The event types to add the event listeners for, like `click mouseenter`
* @param {Function} handler The handler to call when the event is triggered.
* @param {object} [options] The options to pass to the event listeners
* @example
* addListenerForEvents('.foo', 'click mouseenter', (e) => { console.log(e.type) })
*/
export function addListenerForEvents(elements, events, handler, options) {
if (elements instanceof Element) elements = [elements]
if (typeof elements === 'string') elements = query(elements)
const eventTypes = isArray(events) ? events : events.split(' ')
for (const element of elements) {
for (const eventType of eventTypes) {
element.addEventListener(eventType, handler, options)
}
}
}
/**
* Removes one listener from multiple registered events
*
* @param {string|HTMLElement|NodeList} elements The elements or a selector for elements to remove the event listeners from
* @param {string|Array<string>} events The event types to remove the event listeners for, like `click mouseenter`
* @param {Function} handler The handler to remove
* @param {object} [options] The options to pass to the event listeners
* @example
* removeListenerForEvents('.foo', 'click mouseenter', (e) => { console.log(e.type) })
*/
export function removeListenerForEvents(elements, events, handler, options) {
if (elements instanceof Element) elements = [elements]
if (typeof elements === 'string') elements = query(elements)
const eventTypes = isArray(events) ? events : events.split(' ')
for (const element of elements) {
for (const eventType of eventTypes) {
element.removeEventListener(eventType, handler, options)
}
}
}
/**
* Resizes an element to cover its parent element while maintaining the aspect ratio
*
* @param {string|HTMLElement|NodeList} elements The elements or a selector for elements to resize
* @param {number} [ratio=1] The ratio to maintain
* @param {number} [offset=0] An offset to add to the parent element's width and height
* @example
* proportionalParentCoverResize('.foo', 16/9, 10)
*/
export function proportionalParentCoverResize(elements, ratio = 1, offset = 0) {
if (elements instanceof Element) elements = [elements]
if (typeof elements === 'string') elements = query(elements)
for (const element of elements) {
const h = element.parentNode.offsetHeight + offset
const w = element.parentNode.offsetWidth + offset
if (ratio > w/h) {
element.style.width = h*ratio + 'px'
element.style.height = h + 'px'
} else {
element.style.width = w + 'px'
element.style.height = w/ratio + 'px'
}
}
}
/**
* If provided element is visible. Checks if the element is not visibility hidden or display none, has no opacity, and has a width and height.
*
* Answers `false` for everything where there is no layout to consult - jsdom, and any other DOM
* without a renderer behind it - because the fallback path ends on the element having a box, and
* nothing in such an environment ever has one. That is honest rather than useful: a DOM that
* renders nothing cannot say what is visible. Code that has to run under one wants a narrower
* question it can actually answer - whether the element sits inside a `[hidden]` ancestor, say.
*
* @param {HTMLElement} element The element to check
* @returns {boolean} True if the element is visible, false otherwise
*
* @example
* isVisible(document.getElementById('foo'))
*/
export function isVisible(element, checkOpacity = true) {
if (!element || !(element instanceof HTMLElement)) return false
if (typeof element.checkVisibility === 'function') return element.checkVisibility({ visibilityProperty: true, opacityProperty: checkOpacity })
if (element.getAttribute('hidden') !== null) return false;
const computedStyle = getComputedStyle(element)
if (
computedStyle.getPropertyValue('display') === 'none' ||
computedStyle.getPropertyValue('visibility') === 'hidden' ||
(checkOpacity && computedStyle.getPropertyValue('opacity') === '0')
)
return false
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
}
/**
* Returns all focusable elements from a given element or the document.
* Focusable elements are those that can be focused by the user, such as links, buttons, inputs, etc.
* Always use getVisibleFocusableElements instead of this function to ensure that only visible elements are returned, since only visible elements can be focused by the user.
*
* @see {@link getVisibleFocusableElements}
* @param {HTMLElement|Element|Document} [from=document] The element to get the focusable elements from
* @returns {Array<HTMLElement>} An array of focusable elements
* @example
* document.body.innerHTML = `
* <div id="foo" tabindex="0">Foo</div>
* <div id="bar" tabindex="-1">Bar</div>
* <button id="baz">Baz</button>
* <div id="qux" contenteditable="true">Qux</div>`
* <a href="#quux" id="quux">Quux</a>
*
* getFocusableElements() // => [<div id="foo" tabindex="0">Foo</div>, <button id="baz">Baz</button>, <div id="qux" contenteditable="true">Qux</div>, <a href="#quux" id="quux">Quux</a>]
*/
export function getFocusableElements(from = document) {
if (from instanceof Element || from instanceof Document) {
return Array.from(from.querySelectorAll('a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"]), video, audio, summary'))
}
if (isString(from)) from = query(from)
if (!isArray(from) && !(from instanceof NodeList)) return []
const res = []
for (const element of from) {
res.push(...getFocusableElements(element))
}
return res
}
/**
* Returns all visible focusable elements from a given element or the document.
*
* Empty where there is no layout to consult, since {@link isVisible} decides every row.
*
* @see {@link getFocusableElements}
* @see {@link isVisible}
* @param {HTMLElement|Element|Document} [from=document] The element to get the focusable elements from
* @param {string} [excludeSelector] A selector to exclude elements from the result
* @returns {Array<HTMLElement>} An array of visible focusable elements
*
* @example
* document.body.innerHTML = `
* <div id="foo" tabindex="0">Foo</div>
* <div id="bar" tabindex="-1">Bar</div>
* <button id="baz" style="display: none;">Baz</button>
* <div id="qux" contenteditable="true">Qux</div>`
*
* getVisibleFocusableElements() // => [<div id="foo" tabindex="0">Foo</div>, <div id="qux" contenteditable="true">Qux</div>]
* getVisibleFocusableElements(document.body, '#foo') // => [<div id="qux" contenteditable="true">Qux</div>]
*/
export function getVisibleFocusableElements(from = document, excludeSelector) {
const elements = getFocusableElements(from)
return Array.from(elements).filter(el => isVisible(el, false) && (!excludeSelector || !el.matches(excludeSelector)))
}
/**
* Swipe event handler
*
* Two points make the gesture - where the pointer went down and where it came up - so there is
* no `pointermove` listener running on every frame of every scroll down the page. The axis it
* travelled furthest along is the one reported, and a gesture as far down as across is neither.
*
* @param {HTMLElement} element The element to listen for swipe gestures on
* @param {object | Function} callback The callback to call when a swipe gesture is detected or the options object with the callback, threshold, timeThreshold and mouse
* @param {number} [threshold=150] The threshold in pixels to trigger the callback.
* @param {number} [timeThreshold=0] The threshold in milliseconds to trigger the callback. Defaults to 0, which means the callback will be called regardless of the time it took to swipe.
* @param {boolean} [mouse=true] Whether a mouse drag counts as a swipe. Options object only. Turn it off on anything with selectable text, draggable images or links in it - reading a swipe from a mouse costs the page all three.
* @returns {object | null} The destroy method to remove the event listeners, or null without an element
* @example
* swipe(document.getElementById('foo'), (e) => {
* console.log(e.direction)
* console.log(e.deltaX)
* console.log(e.deltaY)
* console.log(e.startX)
* console.log(e.startY)
* console.log(e.endX)
* console.log(e.endY)
* console.log(e.threshold)
* console.log(e.type)
* console.log(e.target)
* console.log(e.horizontal)
* console.log(e.vertical)
* console.log(e.horizontalDirection)
* console.log(e.verticalDirection)
* console.log(e.timeElapsed)
* console.log(e.timeThreshold)
* })
*
* swipe(document.getElementById('foo'), { callback: onSwipe, threshold: 40, mouse: false })
*
* element.addEventListener('swipe', (e) => { ... })
* element.addEventListener('swipestart', (e) => { ... })
* element.addEventListener('swipeend', (e) => { ... })
*/
export function swipe(element, callback, threshold = 150, timeThreshold = 0) {
let mouse = true
let start = null
let swiped = false
if (isObject(callback)) {
const options = callback
callback = options.callback
threshold = options.threshold || threshold
timeThreshold = options.timeThreshold || timeThreshold
if (options.mouse === false) mouse = false
}
if (!element) return null
const handleStart = function(e) {
swiped = false
// A second pointer while one is already down is a pinch, and the first one's numbers are
// noise from here on, so the gesture is dropped rather than answered wrongly.
if (start || (!mouse && e.pointerType === 'mouse')) {
start = null
return
}
start = { id: e.pointerId, x: e.clientX, y: e.clientY, time: Date.now() }
// Touch and pen are captured implicitly by the browser, the mouse is not - without this a
// mouse swipe that ends off the element never reports its `pointerup`. It throws when the
// id is not an active pointer, which a synthesised event is.
if (element.setPointerCapture) {
try { element.setPointerCapture(e.pointerId) } catch { /* the gesture still ends on the element */ }
}
element.dispatchEvent(new CustomEvent('swipestart', { detail: { target: element, startX: start.x, startY: start.y, startTime: start.time } }))
}
const handleEnd = function(e) {
const from = start
start = null
if (!from || e.pointerId !== from.id) return
const endX = e.clientX
const endY = e.clientY
const endTime = Date.now()
const deltaX = Math.abs(endX - from.x)
const deltaY = Math.abs(endY - from.y)
const left = endX < from.x
const up = endY < from.y
const horizontal = deltaX > deltaY && deltaX > threshold
const vertical = deltaY > deltaX && deltaY > threshold
const timeElapsed = endTime - from.time
if (horizontal || vertical) {
if (!timeThreshold || timeElapsed <= timeThreshold) {
const res = {
target: element,
deltaX: deltaX,
deltaY: deltaY,
startX: from.x,
startY: from.y,
endX: endX,
endY: endY,
threshold: threshold,
horizontal: horizontal,
vertical: vertical,
horizontalDirection: left ? 'left' : 'right',
verticalDirection: up ? 'up' : 'down',
direction: horizontal ? (left ? 'left' : 'right') : (up ? 'up' : 'down'),
timeElapsed: timeElapsed,
timeThreshold: timeThreshold
}
swiped = true
if (isFunction(callback)) callback(res)
element.dispatchEvent(new CustomEvent('swipe', { detail: res }))
}
}
element.dispatchEvent(new CustomEvent('swipeend', { detail: { target: element, startX: from.x, startY: from.y, startTime: from.time, endX, endY, endTime } }))
}
// The browser took the gesture - a scroll started under it, or the finger left the screen's
// edge. Whatever it became, it is not a swipe.
const handleCancel = function() {
start = null
}
// A swipe that ends on a link fires a click too, and the browser only stops synthesising one
// after a slop most thresholds are past - most, not all, and the one that still fires
// navigates away from a page the reader was swiping through. `detail` is what keeps this off
// the keyboard: Enter on a link reports zero pointer clicks.
const handleClick = function(e) {
if (!swiped) return
swiped = false
if (!e.detail) return
e.preventDefault()
e.stopPropagation()
}
element.addEventListener('pointerdown', handleStart)
element.addEventListener('pointerup', handleEnd)
element.addEventListener('pointercancel', handleCancel)
element.addEventListener('click', handleClick, true)
return {
destroy: function() {
element.removeEventListener('pointerdown', handleStart)
element.removeEventListener('pointerup', handleEnd)
element.removeEventListener('pointercancel', handleCancel)
element.removeEventListener('click', handleClick, true)
start = null
swiped = false
}
}
}
/**
* Alias for swipe
*
* @see swipe
* @deprecated Use swipe instead
*/
export const onSwipe = swipe
// Elements with a gesture in flight, whichever way it came in. A second `pointerdown` on one of
// them is a pinch, and the first pointer's numbers are noise from then on - so it is refused
// rather than allowed to fight the gesture in hand. Module-wide, because a delegated caller
// starts a fresh instance per event and no instance can see the others.
const dragging = new WeakSet()
/**
* Drag event handler
*
* Pointer events, and one gesture at a time. The pointer is captured on the way in - touch and
* pen the browser captures implicitly, the mouse it does not - so a mouse drag keeps reporting
* after it has left the element, and whatever the pointer crosses on the way hears nothing of
* it. What capture does not survive is the element leaving the document: the moment it is
* disconnected the spec hands the capture to the document, and `insertBefore` on a connected
* node disconnects it first, so a list that reorders itself by moving the dragged row loses it
* on the first crossing. The moves are heard on the document for as long as the gesture lasts,
* which is where they land either way. Everything this needs - pointer events, pointer capture,
* `touch-action` - is in Safari 13 and iOS 13.2; below that the listener attaches and nothing
* ever arrives.
*
* **Two ways in.** Hand it an element and it waits for a `pointerdown` of its own, for as long
* as it is attached. Hand it a `pointerdown` already in flight and it starts that one gesture
* now, taking its listeners away again when the pointer is let go - which is what a caller with
* a single *delegated* listener has, and the only shape that works over a list whose rows come
* and go. Attaching per row is a listener per row and a re-attach every time the list grows one;
* delegating is one listener whatever the list does.
*
* Started from an event, nothing is written into the element: no `drag-enabled`, no `dragging`,
* no `touch-action`. It is an element the caller already owns and has already styled, and
* `touch-action` is decided long before a `pointerdown` is dispatched, so setting it there would
* be a promise this cannot keep - put it on the handle in CSS.
*
* A gesture the platform takes away - a scroll it decided was one, a call arriving - reports
* `dragcancel` and no `dragend`, and never coasts into inertia. That distinction is the whole
* reason to listen for it: a cancelled drag is not a drag the person finished.
*
* **`within` is what makes the percentages worth reading.** They are measured against the element
* being dragged unless it names another, and a handle is a few pixels of the track it runs
* along - so `xPercentage` off a 24px handle answers a question about the handle, and the caller
* wanting "how far along the track is this" has to do the sum itself. Pointed at the track it is
* that number directly, held to 0-100 at the ends, which is a slider, a splitter or a
* before-and-after comparison in one read. `maxVelocity: '0.5%'` caps the flick in that unit too.
*
* **`axis` is for a handle on a track.** The glide is two-dimensional and over once both velocities
* have decayed, and a slider reads one of them: a flick along a horizontal track carries some
* velocity across it too, and that one can outlive the one along the track by most of a second -
* the handle sat still at the wall, `draginertiaend` not yet said. Named, the other axis reads
* `0` in every `velocityX`/`velocityY` and carries nothing, so the glide is over when this one is.
*
* `preventDefaultTouch` owns the touch gesture by putting `touch-action: none` on the element
* for as long as the handler is attached, restored by `destroy`. Preventing the default on the
* events cannot do it: by the time a `pointermove` arrives the browser has already decided the
* gesture is a scroll.
*
* The events are named `dragstart`, `drag` and `dragend`, which are also the names the native
* HTML drag and drop API uses. A page listening for the native ones on the same element will
* hear these too. Renaming them is a breaking change and has not been made; `callback` is the
* way to take one element's drags without that.
*
* **What shaped it.** [compare-images-slider](https://github.com/stamat/compare-images-slider) is
* the handle on a track: `within`, `axis`, and a flick capped in per cent because a comparison
* slider is the same gesture at every width. `splitter-elemental` in
* [book-of-elements](https://github.com/stamat/book-of-elements) is that read on either axis, and
* `rearrange-elemental` is the delegated way in - one `pointerdown` over rows that come and go,
* losing the pointer capture on the first `insertBefore`. Every option above is one of those
* three hitting a wall, not a guess at what a caller might want.
*
* @param {HTMLElement | PointerEvent} target The element to listen for drag gestures on, or a `pointerdown` already in hand to start one gesture from now
* @param {object | Function} opts The options object or the callback to call when a drag gesture is detected
* @param {HTMLElement} [opts.target] Where to capture the pointer and dispatch the events, when started from an event. Defaults to that event's `currentTarget`, then its `target` - name it when the listener is on a container and the gesture belongs to a handle inside it
* @param {HTMLElement} [opts.within] The box `relativeX`/`relativeY` and `xPercentage`/`yPercentage` are measured against, and the one inertia bounces off. Defaults to the element being dragged; name the track when the thing being dragged is a handle running along one. Anything that is not an element falls back to that default
* @param {boolean} [opts.inertia=false] Whether to enable inertia
* @param {boolean} [opts.bounce=false] Whether to enable bounce when inertia is enabled
* @param {number} [opts.friction=0.9] The friction to apply when inertia is enabled
* @param {number} [opts.bounceFactor=0.2] The bounce factor to apply when bounce is enabled
* @param {number} [opts.velocityWindow=80] Time window (ms) over which flick velocity is measured, ending at the release - a drag held still before letting go carries none
* @param {number|string} [opts.maxVelocity=2] Cap on flick velocity magnitude, in pixels per millisecond - or, as a string ending in `%`, in per cent of `within` per millisecond, width for x and height for y: the unit the percentages are in, and the one that keeps the same flick reading the same on a narrow track and a wide one. Anything that does not parse as a number is the default
* @param {'x'|'y'} [opts.axis] The axis the flick and the glide run along; the velocity across it reads `0` and carries nothing. Anything else is both axes
* @param {boolean} [opts.preventDefaultTouch=true] Whether to take the touch gesture, with `touch-action: none` on the element. Ignored when started from an event - by then the browser has already decided whether the gesture is a scroll, so the handle's stylesheet is the only place it can be said
* @param {Function} [opts.callback] The callback to call when a drag gesture is detected
* @returns {object | undefined} The destroy method to remove the event listeners; nothing when refused - not an element, already attached, or already mid-gesture
* @example
* drag(document.getElementById('foo'), (e) => {
* console.log(e.x) // page coordinates
* console.log(e.y)
* console.log(e.clientX) // viewport coordinates, what getBoundingClientRect answers in
* console.log(e.clientY)
* console.log(e.relativeX)
* console.log(e.relativeY)
* console.log(e.xPercentage)
* console.log(e.yPercentage)
* console.log(e.velocityX)
* console.log(e.velocityY)
* console.log(e.prevX)
* console.log(e.prevY)
* console.log(e.pointerType) // 'mouse', 'touch' or 'pen'
* })
*
* // a handle running along a track: 0-100 along the track, not along the handle, gliding along
* // it alone after a flick capped at half a per cent of the track per millisecond
* drag(handle, { within: track, inertia: true, axis: 'x', maxVelocity: '0.5%', callback: (d) => setPosition(d.xPercentage) })
*
* // one delegated listener over a list, whatever the list does next
* list.addEventListener('pointerdown', (e) => {
* const handle = e.target.closest('[data-handle]')
* if (handle) drag(e, { target: handle, callback: (d) => reorder(d.clientY) })
* })
*
* element.addEventListener('drag', (e) => { ... })
* element.addEventListener('dragstart', (e) => { ... })
* element.addEventListener('dragend', (e) => { ... })
* element.addEventListener('dragcancel', (e) => { ... })
* element.addEventListener('draginertia', (e) => { ... })
* element.addEventListener('draginertiaend', (e) => { ... })
*/
export function drag(target, opts) {
// Two ways in, one gesture engine. An element is the standing offer - it waits for a
// `pointerdown` of its own, for as long as it is attached. A `pointerdown` already in hand is
// one gesture, starting now: which is what a caller with a *delegated* listener has, and the
// only shape that works over a list whose rows come and go. Told apart by the event's own
// type, so nothing has to be declared.
const fromEvent = !!target && typeof target === 'object' && target.type === 'pointerdown' && 'pointerId' in target
const element = fromEvent ? ((isObject(opts) && opts.target) || target.currentTarget || target.target) : target
if (!element || !(element instanceof Element)) return
// The attribute guard is the standing offer's, not the gesture's: two `pointerdown` listeners
// on one element is the mistake worth refusing, and two gestures in a row over the same
// element is the normal case.
if (!fromEvent && element.getAttribute('drag-enabled') === 'true') return
if (fromEvent && dragging.has(element)) return
const doc = element.ownerDocument
let x = 0
let y = 0
let clientX = 0
let clientY = 0
let prevX = 0
let prevY = 0
let velocityX = 0
let velocityY = 0
// `null` between gestures. No event carries `null`, so comparing ids says in one check whether
// a gesture is running and whether this pointer is the one running it.
let pointerId = null
let pointerType = ''
let rect = null
let inertiaId = null
let inertiaTime = 0
let samples = []
const options = {
within: null,
inertia: false,
bounce: false,
friction: 0.9,
bounceFactor: 0.2,
velocityWindow: 80,
maxVelocity: 2,
axis: null,
callback: null,
preventDefaultTouch: true
}
if (isFunction(opts)) {
options.callback = opts
} else if (isObject(opts)) {
shallowMerge(options, opts)
}
options.friction = Math.abs(options.friction)
options.bounceFactor = Math.abs(options.bounceFactor)
// A number is pixels per millisecond; a string ending in `%` is per cent of the measured box per
// millisecond, resolved into pixels at each press once the box is measured. A cap that does not
// parse is the default rather than a `NaN`, which `clamp` would read as no cap at all.
const capInPercent = typeof options.maxVelocity === 'string' && options.maxVelocity.trim().endsWith('%')
const cap = Math.abs(parseFloat(options.maxVelocity))
options.maxVelocity = Number.isNaN(cap) ? 2 : cap
let capX = options.maxVelocity
let capY = options.maxVelocity
// The window and the cap are `sampleVelocity` and `clamp` in helpers, because a flick measured
// over a window is the same sum wherever it is measured - and a caller sampling its own
// gesture in percent rather than in pixels is the second one that wanted it.
const measureVelocity = function() {
const v = sampleVelocity(samples, options.velocityWindow)
velocityX = options.axis === 'y' ? 0 : clamp(v.x || 0, -capX, capX)
velocityY = options.axis === 'x' ? 0 : clamp(v.y || 0, -capY, capY)
}
// Attributes and `touch-action` belong to the standing offer alone. Started from an event,
// this element is one the caller already owns and has already styled - writing undocumented
// attributes into it is the thing a delegated caller least wants - and `touch-action` is
// decided long before a `pointerdown` is dispatched, so setting it here would be a promise
// this cannot keep. The handle's stylesheet is where it belongs; see the note on the option.
if (!fromEvent) {
element.setAttribute('drag-enabled', 'true')
element.setAttribute('dragging', 'false')
}
// Kept so `destroy` can put back what the page had, rather than what this decided. Coerced,
// because an unset property is `''` in a browser and `undefined` where the style object does
// not model `touch-action` at all - and restoring `undefined` writes the word.
const ownTouchAction = element.style.touchAction || ''
if (!fromEvent && options.preventDefaultTouch) element.style.touchAction = 'none'
// What the relative coordinates and the percentages are measured against, and what inertia
// bounces off: the element being dragged, unless `within` names the box it is being dragged
// inside. A handle is 24px of the track it runs along, so a percentage of the handle is a
// number about the wrong box.
const measured = options.within instanceof Element ? options.within : element
const calcPageRelativeRect = function() {
const origRect = measured.getBoundingClientRect()
const rect = {
top: origRect.top + window.scrollY,
left: origRect.left + window.scrollX,
width: origRect.width,
height: origRect.height
}
return rect
}
const handleStart = function(e) {
if (dragging.has(element)) return
dragging.add(element)
samples = []
pointerId = e.pointerId
pointerType = e.pointerType || ''
setXY(e)
// The gesture starts where it starts. Without this the first `drag` of a new gesture
// reports the last one's coordinates as `prevX`/`prevY`.
prevX = x
prevY = y
rect = calcPageRelativeRect()
if (capInPercent) {
capX = rect.width * options.maxVelocity / 100
capY = rect.height * options.maxVelocity / 100
}
if (!fromEvent) element.setAttribute('dragging', 'true')
// Touch and pen the browser captures implicitly, the mouse it does not - without this a
// mouse drag that leaves the element stops reporting. It throws when the id is not an
// active pointer, which a synthesised event is, or when the element is not connected.
if (element.setPointerCapture) {
try { element.setPointerCapture(e.pointerId) } catch { /* the document hears the gesture regardless */ }
}
// On the document, not the element: capture goes to the document the moment the captured
// element is disconnected, and `insertBefore` on a connected node disconnects it first - a
// row moved in the DOM mid-drag would stop hearing its own gesture.
doc.addEventListener('pointermove', handleMove)
doc.addEventListener('pointerup', handleEnd)
doc.addEventListener('pointercancel', handleCancel)
if (inertiaId) {
cancelAnimationFrame(inertiaId)
inertiaId = null
}
const event = new CustomEvent('dragstart', { detail: getDetail() })
element.dispatchEvent(event)
}
const handleMove = function(e) {
if (e.pointerId !== pointerId) return
// A pointer that reports the same place twice has not moved, and a `drag` carrying a delta
// of zero tells the caller nothing it can act on. Browsers do send these - a pressure or
// tilt change on a pen is a `pointermove` - and the caller's handler is the expensive part.
// Both pairs, because they can move apart: the page scrolling under a pointer that has not
// moved changes `pageY` and leaves `clientY` where it was, and a caller reading page
// coordinates is owed that one.
if (e.clientX === clientX && e.clientY === clientY && e.pageX === x && e.pageY === y) return
setXY(e)
measureVelocity()
const detail = getDetail()
if (options.callback) options.callback(detail)
const event = new CustomEvent('drag', { detail: detail })
element.dispatchEvent(event)
}
const stop = function() {
dragging.delete(element)
if (!fromEvent) element.setAttribute('dragging', 'false')
doc.removeEventListener('pointermove', handleMove)
doc.removeEventListener('pointerup', handleEnd)
doc.removeEventListener('pointercancel', handleCancel)
if (element.hasPointerCapture && element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId)
pointerId = null
}
const handleEnd = function(e) {
if (e.pointerId !== pointerId) return
stop()
// The flick is what the pointer was doing as it let go, not the last time it moved: without
// a sample at the release, a drag held still before lifting coasts off wherever it was last
// heading.
const t = performance.now()
samples.push({ t: t, x: x, y: y })
measureVelocity()
inertiaTime = t
if (options.inertia) inertiaId = requestAnimationFrame(inertia)
const event = new CustomEvent('dragend', { detail: getDetail() })
element.dispatchEvent(event)
}
// A cancelled gesture is not a finished one: no `dragend`, no throw, and the velocity it was
// carrying is dropped rather than spent coasting somewhere the person never let go.
const handleCancel = function(e) {
if (e.pointerId !== pointerId) return
stop()
velocityX = 0
velocityY = 0
samples = []
const event = new CustomEvent('dragcancel', { detail: getDetail() })
element.dispatchEvent(event)
}
const setXY = function(e) {
prevX = x
prevY = y
x = e.pageX
y = e.pageY
clientX = e.clientX
clientY = e.clientY
samples.push({ t: performance.now(), x: x, y: y })
if (samples.length > 12) samples.shift()
}
const getDetail = function() {
const relativeX = x - rect.left
const relativeY = y - rect.top
const xPercentage = percentage(relativeX, rect.width)
const yPercentage = percentage(relativeY, rect.height)
const detail = {
target: element,
x: x,
y: y,
clientX: clientX,
clientY: clientY,
relativeX: relativeX,
relativeY: relativeY,
xPercentage: xPercentage,
yPercentage: yPercentage,
velocityX: velocityX,
velocityY: velocityY,
prevX: prevX,
prevY: prevY,
pointerType: pointerType
}
if (xPercentage < 0) detail.xPercentage = 0
if (xPercentage > 100) detail.xPercentage = 100
if (yPercentage < 0) detail.yPercentage = 0
if (yPercentage > 100) detail.yPercentage = 100
return detail
}
const inertia = function() {
const t = performance.now()
const dt = t - inertiaTime
inertiaTime = t
x += velocityX * dt
y += velocityY * dt
const decay = Math.pow(options.friction, dt / 16.6667)
velocityX *= decay
velocityY *= decay
if (options.bounce) {
if (x < rect.left) {
x = rect.left
velocityX *= -options.bounceFactor
}
if (x > rect.width + rect.left) {
x = rect.width + rect.left
velocityX *= -options.bounceFactor
}
if (y < rect.top) {
y = rect.top
velocityY *= -options.bounceFactor
}
if (y > rect.height + rect.top) {
y = rect.height + rect.top
velocityY *= -options.bounceFactor
}
}
if (Math.abs(velocityX) < 0.01) velocityX = 0
if (Math.abs(velocityY) < 0.01) velocityY = 0
const detail = getDetail()
if (velocityX !== 0 || velocityY !== 0) {
// Booked before the caller hears the frame: a `destroy()` from inside the callback or the
// event then cancels this frame, rather than the one already spent, and the glide stops.
inertiaId = requestAnimationFrame(inertia)
if (options.callback) options.callback(detail)
const event = new CustomEvent('draginertia', { detail: detail })
element.dispatchEvent(event)
} else {
inertiaId = null
if (options.callback) options.callback(detail)
const event = new CustomEvent('draginertiaend', { detail: detail })
element.dispatchEvent(event)
}
}
if (fromEvent) handleStart(target)
else element.addEventListener('pointerdown', handleStart)
return {
//TODO: add manual start, move and end methods - for programmatic control
destroy: function() {
if (pointerId !== null) stop()
if (!fromEvent) {
element.removeEventListener('pointerdown', handleStart)
element.style.touchAction = ownTouchAction
element.removeAttribute('drag-enabled')
element.removeAttribute('dragging')
}
if (inertiaId) {
cancelAnimationFrame(inertiaId)
inertiaId = null
}
}
}
}
/**
* Alias for drag
*
* @see drag
* @deprecated Use drag instead
*/
export const onDrag = drag
/**
* Checks if an element is overflowing its container
* used to check if the scrollbar is visible.
*
* @param {HTMLElement} element The element to check
* @returns {boolean} True if the element is overflowing, false otherwise
* @example
* const el = document.getElementById('foo')
* isVerticalScrollVisible(el) // => true or false
*/
export function isVerticalScrollVisible(element) {
if (!element || !(element instanceof HTMLElement)) return false
return element.offsetHeight < element.scrollHeight
}
/**
* Checks if an element is overflowing horizontally
* used to check if the scrollbar is visible.
*
* @param {HTMLElement} element The element to check
* @returns {boolean} True if the element is overflowing, false otherwise
* @example
* const el = document.getElementById('foo')
* isHorizontalScrollVisible(el) // => true or false
*/
export function isHorizontalScrollVisible(element) {
if (!element || !(element instanceof HTMLElement)) return false
return element.offsetWidth < element.scrollWidth
}
/**
* Checks if an element is overflowing its container either vertically or horizontally
* used to check if the scrollbar is visible.
*
* @param {HTMLElement} element The element to check
* @returns {boolean} True if the element is overflowing, false otherwise
* @example
* const el = document.getElementById('foo')
* isScrollVisible(el) // => true or false
*/
export function isScrollVisible(element) {
if (!element || !(element instanceof HTMLElement)) return false
return isVerticalScrollVisible(element) || isHorizontalScrollVisible(element)
}
/**
* Gets if the vertical scroll has reached the start or end of the element.
*
* @param {HTMLElement} element The element to check
* @param {number} [threshold=0] The threshold in pixels to consider the scroll as at the start or end
* @returns {object} An object with `atStart` and `atEnd` properties indicating if the scroll is at the start or end of the element
* @example
* const el = document.getElementById('foo')
* const scrollState = getVerticalScrollState(el, 10)
* console.log(scrollState.atStart) // => true or false
* console.log(scrollState.atEnd) // => true or false
*/
export function getVerticalScrollState(element, threshold = 0) {
const noState = {
atStart: false,
atEnd: false
}
if (!element || !(element instanceof HTMLElement)) return noState
if (!isVerticalScrollVisible(element)) return noState
const { scrollTop, scrollHeight, clientHeight } = element
const scrollBottom = scrollHeight - scrollTop - clientHeight
const scrollEndThreshold = scrollBottom <= threshold
const scrollStartThreshold = scrollTop <= threshold
return {
atStart: scrollStartThreshold,
atEnd: scrollEndThreshold
}
}
/**
* Gets if the horizontal scroll has reached the start or end of the element.
*
* @param {HTMLElement} element The element to check
* @param {number} [threshold=0] The threshold in pixels to consider the scroll as at the start or end
* @returns {object} An object with `atStart` and `atEnd` properties indicating if the scroll is at the start or end of the element
* @example
* const el = document.getElementById('foo')
* const scrollState = getHorizontalScrollState(el, 10)
* console.log(scrollState.atStart) // => true or false
* console.log(scrollState.atEnd) // => true or false
*/
export function getHorizontalScrollState(element, threshold = 0) {
const noState = {
atStart: false,
atEnd: false
}
if (!element || !(element instanceof HTMLElement)) return noState
if (!isHorizontalScrollVisible(element)) return noState
const { scrollLeft, scrollWidth, clientWidth } = element
const scrollRight = scrollWidth - scrollLeft - clientWidth
const scrollEndThreshold = scrollRight <= threshold
const scrollStartThreshold = scrollLeft <= threshold
return {
atStart: scrollStartThreshold,
atEnd: scrollEndThreshold
}
}
// The overflow values that create a scrolling mechanism, which is what a sticky element sticks
// inside. `clip` is absent by design: it clips without becoming a scrollport, which is what makes
// it the fix for a broken sticky rather than another cause of one.
const SCROLLPORT_OVERFLOW = new Set(['hidden', 'scroll', 'auto', 'overlay'])
const STICKY_AXES = [
{ insets: ['top', 'bottom'], overflow: 'overflowY', css: 'overflow-y', size: 'height', scrolls: isVerticalScrollVisible },
{ insets: ['left', 'right'], overflow: 'overflowX', css: 'overflow-x', size: 'width', scrolls: isHorizontalScrollVisible }
]
// endsWith rather than equality because old Safari computes `position: -webkit-sticky`.
const isSticky = function (style) {
return style.position.endsWith('sticky')
}
function stickyFindings(element) {
// Every style of a detached element computes to the empty string in a real browser — jsdom
// resolves inline styles regardless — so the checks below would read it as not sticky and
// prescribe `position: sticky` to an element that already says so.
if (!element.isConnected) {
return [{
code: 'detached',
element: element,
culprit: element,
problem: 'the element is not in the document, so nothing about it computes',
fix: 'attach it, then ask again'
}]
}
const style = getComputedStyle(element)
if (!isSticky(style)) {
return [{
code: 'not-sticky',
element: element,
culprit: element,
problem: 'position is `' + style.position + '`',
fix: 'set `position: sticky`'
}]
}
const findings = []
// An unset inset computes to `auto` in a browser and to an empty string in jsdom, so both
// have to count as unset or the check passes for the wrong reason under test.
const axes = STICKY_AXES.filter(function (axis) {
return axis.insets.some(function (side) { return style[side] && style[side] !== 'auto' })
})
if (!axes.length) {
findings.push({
code: 'no-inset',
element: element,
culprit: element,
problem: 'every inset is `auto`, so there is no threshold to stick at',
fix: 'set `top`, `bottom`, `left` or `right`'
})
}
const parent = element.parentElement
if (parent) {
const parentRect = parent.getBoundingClientRect()
const rect = element.getBoundingClientRect()
for (const axis of axes) {
const room = parentRect[axis.size] - rect[axis.size]
if (room > 0) continue
findings.push({
code: 'no-room',
element: element,
culprit: parent,
problem: 'the containing block is no larger than the element, leaving ' + Math.round(room) + 'px to travel',
fix: 'give the parent room — `align-self: flex-start` on a stretched flex item, or a taller wrapper'
})
}
}
// Only the nearest scrolling ancestor matters: it is the scrollport, and anything above it is
// out of reach. `html` and `body` are skipped because a non-visible overflow on either
// propagates to the viewport, leaving the body itself treated as visible.
for (let node = element.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
const ancestorStyle = getComputedStyle(node)
const axis = STICKY_AXES.find(function (candidate) {
return SCROLLPORT_OVERFLOW.has(ancestorStyle[candidate.overflow])
})
if (!axis) continue
// The property is named in CSS syntax, not as the JS style key: the fix is meant to be
// pasted into a stylesheet, where `overflowY: clip` would be silently ignored.
const declaration = '`' + axis.css + ': ' + ancestorStyle[axis.overflow] + '`'
// Judged on the matched axis alone: a live scrollbar on the other axis moves nothing here.
findings.push(axis.scrolls(node) ? {
code: 'nested-scrollport',
element: element,
culprit: node,
problem: 'sticks inside this ancestor’s scrollport (' + declaration + ') rather than to the viewport',
fix: 'intended? then nothing to fix. Otherwise move the element out of this ancestor'
} : {
code: 'dead-scrollport',
element: element,
culprit: node,
problem: 'this ancestor is the scrollport (' + declaration + ') and never scrolls, so the element can never move',
fix: '`' + axis.css + ': clip` clips the same and creates no scrollport'
})
break
}
return findings
}
/**
* Reports why a sticky element cannot stick.
*
* `position: sticky` fails silently: no error, no warning, an element that simply never moves.
* The usual cause is an ancestor with `overflow: hidden`, `scroll`, `auto` or `overlay`, which
* becomes the scrollport the element sticks inside — and when that ancestor does not itself
* scroll, there is nowhere for the element to go. No DevTools reports it: neither engine's
* inactive-CSS pass has a rule for sticky, since the declaration is valid and applied, and their
* `scroll` badges mark containers that really do scroll — where the one that breaks stickiness is
* a box that never scrolls at all. This reads the ancestors and names the culprit.
*
* Each finding carries a `code`, the `element` it is about, the `culprit` element to look at,
* the `problem` in one sentence and a `fix`. The codes are `detached` (not in the document, so
* nothing about it computes), `not-sticky`, `no-inset` (every inset
* is `auto`, so there is no threshold to stick at), `no-room` (the containing block is no larger
* than the element), `dead-scrollport` (an ancestor is the scrollport and never scrolls) and
* `nested-scrollport` (the element sticks inside an ancestor rather than to the viewport, which
* may well be what you meant).
*
* An empty array means nothing here can see a reason, never that the element sticks. Three things
* it cannot see: `contain: paint`, `contain: layout` and `content-visibility` on an ancestor;
* `html` and `body` overflow, skipped because a non-visible value there propagates to the viewport
* and reporting it would fire on the most ordinary markup there is; and a containing block that is
* not the element's parent, since `no-room` measures the parent.
*
* @param {string|Element|Array<Element>|NodeList} [target] The element or elements to diagnose, or a selector for them. Left out, every sticky element on the page is diagnosed
* @returns {Array<object>} One finding per problem found, `[]` when none were
* @example
* // In the console: every sticky element on the page, and what is wrong with each
* console.table(whyNotSticky())
* @example
* whyNotSticky('#sidebar')
* // => [{ code: 'dead-scrollport', element: aside#sidebar, culprit: div.wrap,
* // problem: 'this ancestor is the scrollport (`overflow-y: hidden`) and never scrolls...',
* // fix: '`overflow-y: clip` clips the same and creates no scrollport' }]
*/
export function whyNotSticky(target) {
if (typeof document === 'undefined') return []
const elements = target === undefined
? Array.from(document.querySelectorAll('*')).filter(function (element) {
return isSticky(getComputedStyle(element))
})
: Array.from(query(target))
return elements.flatMap(stickyFindings)
}
/**
* Reports which section the reader is currently in, and calls back when that changes.
*
* The current section is the last one whose top edge has passed the reading line — `offset`
* pixels below the top of the viewport — which is not the same as the topmost section on
* screen: a heading scrolled just out of sight is still the section being read. At the very
* bottom of the page the last section wins outright, because a final section shorter than the
* screen can never reach the line and would otherwise be unreachable. Above the first section
* the callback gets `null`, leaving what the top of the page means to the caller.
*
* Driven by a rAF-throttled scroll listener rather than an `IntersectionObserver`: an observer
* fires only when visibility changes, so scrolling from one heading to the next inside a screen
* that already shows both tells it nothing, and it keeps reporting the section before.
*
* Section positions are measured once and cached, so a scrolled frame costs one layout read —
* a document-height check — instead of one per section. The cache rebuilds itself on window
* resize, through a `ResizeObserver` on `document.body` whenever the body changes size, and on
* any scrolled frame where the document's height moved — which is what catches a body pinned to
* 100% height, whose box never resizes while its content grows. An `IntersectionObserver` over
* the sections rides along as a staleness probe: its entries carry each section's rectangle for
* free, so a shift that changed no height anywhere — two sections trading equal heights, a
* transform settling — is caught the moment a moved section crosses a viewport edge. A shift
* that neither changes a height nor crosses an edge answers stale until `update()` is called;
* each observer degrades alone, so a browser missing one keeps every other trigger.
*
* Follows the window's scroll only, not a scrolling container's.
*
* @param {string|Element|Array<Element>|NodeList} elements The sections to watch, or a selector for them
* @param {function} callback Called with the current section and its index, or with `null` and `-1`
* @param {object} [options]
* @param {number|function} [options.offset=0] Pixels below the top of the viewport that count as the reading line — the height of a sticky header, usually. A function is read every frame, so a header that collapses mid-scroll keeps the line honest
* @returns {object|null} `{ update, destroy }`, or `null` when there is no callback or nothing to watch
* @example
* const spy = scrollSpy('.prose h2[id]', (section) => {
* document.querySelectorAll('.toc a').forEach((a) => a.removeAttribute('aria-current'))
* if (section) document.querySelector(`.toc a[href="#${section.id}"]`)?.setAttribute('aria-current', 'location')
* }, { offset: 64 })
*
* spy.update() // re-measure after a layout change the observers cannot see
* spy.destroy() // stop listening
*/
export function scrollSpy(elements, callback, options = {}) {
if (!isFunction(callback)) return null
const sections = Array.from(query(elements)).filter((element) => element && isFunction(element.getBoundingClientRect))
if (!sections.length) return null
// Sorted rather than trusted: the scan below stops at the first section still below the line,
// so an array assembled by hand and passed out of order would report a wrong answer quietly.
sections.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1))
// A function is read every frame rather than once: a collapsing header's height is a
// moving target, and caching it would be the staleness bug this function exists to avoid.
const offset = isFunction(options.offset) ? options.offset : function() { return options.offset || 0 }
let current
let pending = false
let frame = null
let dirty = false
let positions = []
let maxScroll = 0
const measure = function() {
positions = sections.map((section) => section.getBoundingClientRect().top + window.scrollY)
maxScroll = document.documentElement.scrollHeight - window.innerHeight
dirty = false
}
const read = function() {
if (maxScroll > 0 && window.scrollY >= maxScroll - 1) return sections.length - 1
// A pixel of slack: a section scrolled exactly to the line reports a fractional top on a
// display that is not at 1x, and landing a hair short would leave it uncurrent.
const line = window.scrollY + (Number(offset()) || 0) + 1
let index = -1
for (let i = 0; i < positions.length; i++) {
if (positions[i] > line) break
index = i
}
return index
}
const report = function() {
const index = read()
if (index === current) return
current = index
callback(index === -1 ? null : sections[index], index)
}
// The latch is its own flag rather than the frame id, and it is raised before the call: a
// `requestAnimationFrame` that runs its callback synchronously — a polyfill, a test — would
// clear the id and then have it assigned back over the top, and every scroll after the first
// would find a frame still pending and do nothing.
const schedule = function() {
if (pending) return
pending = true
frame = requestAnimationFrame(function() {
pending = false
frame = null
// One layout read per frame rather than none: a body pinned to 100% height grows its
// scrollHeight without resizing its box, so the observer stays silent — and the
// document's height is the one number that moves on any vertical growth worth
// remeasuring. A shift that changes no height at all still waits for update().
if (dirty || document.documentElement.scrollHeight - window.innerHeight !== maxScroll) measure()
report()
})
}
const onScroll = schedule
// Rebuilds ride the same frame latch as scrolls: a height animation fires the observer on
// every step, and without the latch each notification would remeasure every section.
const onLayout = function() {
dirty = true
schedule()
}
const update = function() {
measure()
report()
}
let observer = null
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(onLayout)
observer.observe(document.body)
}
// The intersection observer is a staleness probe, not the answer: its entries carry each
// section's rectangle, computed off the main thread, so a section crossing the viewport
// edge with a rectangle that disagrees with the map means the page shifted without a
// height change — remeasure. The rectangle is captured a beat before delivery, so a fast
// scroll can disagree by the frames in between; that false alarm costs a spare remeasure
// at a crossing, never a wrong answer.
let probe = null
if (typeof IntersectionObserver !== 'undefined') {
const indexes = new Map(sections.map((section, i) => [section, i]))
probe = new IntersectionObserver(function(entries) {
for (const entry of entries) {
const at = entry.boundingClientRect.top + window.scrollY
if (Math.abs(at - positions[indexes.get(entry.target)]) > 1) {
onLayout()
return
}
}
}, { threshold: 0 })
for (const section of sections) probe.observe(section)
}
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onLayout, { passive: true })
update()
return {
update: update,
destroy: function() {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onLayout)
if (observer) observer.disconnect()
observer = null
if (probe) probe.disconnect()
probe = null
if (frame !== null) cancelAnimationFrame(frame)
frame = null
pending = false
}
}
}