/** @module elements */
/**
* `HTMLElement` when there is one, a plain class when there is not (e.g. Node under
* test), so custom element modules stay importable outside the browser.
*
* @example
* class MyElement extends ElementBase {}
*/
export const ElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
/**
* Registers a custom element - in the browser only, and only once, so the module is
* safe to import twice or to import under Node.
*
* @param {string} tag
* @param {Function} ctor
* @example
* define('my-element', MyElement)
* define('my-element', MyElement) // second call is a no-op
*/
export function define(tag, ctor) {
if (typeof customElements === 'undefined' || customElements.get(tag)) return
customElements.define(tag, ctor)
}
/**
* Where an arrow, Home or End key moves focus in a wrapping list of widgets - the key
* map the APG patterns with a roving tabindex (menu, tablist, accordion headers) share.
*
* @param {number} current Index of the currently focused item, `-1` for none
* @param {string} key KeyboardEvent.key value
* @param {number} length Number of items in the list
* @returns {number|null} Target index, or null if the key is unhandled
* @example
* nextIndex(0, 'ArrowDown', 3) // => 1
* nextIndex(2, 'ArrowDown', 3) // => 0, wraps
* nextIndex(0, 'End', 3) // => 2
* nextIndex(0, 'Tab', 3) // => null
*/
export function nextIndex(current, key, length) {
if (length === 0) return null
switch (key) {
case 'ArrowDown':
return (current + 1) % length
case 'ArrowUp':
// `<= 0` rather than a modulo, so both ways of being at the top land on the last
// item: the first item wrapping round, and nothing focused at all - which is the
// documented `-1`, and where Up on a closed menu button opens onto.
return current <= 0 ? length - 1 : current - 1
case 'Home':
return 0
case 'End':
return length - 1
default:
return null
}
}
/**
* Where an arrow, Home or End key moves focus in a list with ends - the non-wrapping
* counterpart of `nextIndex`, for sets where running off one end is how you get back to
* the rest of the page (the APG disclosure navigation, a toolbar).
*
* Answers to both axes, so a horizontal bar and a vertical stack share it.
*
* @param {number} current Index of the focused item, `-1` for none
* @param {string} key KeyboardEvent.key value
* @param {number} length Number of items in the set
* @returns {number|null} Target index, or null if the key is unhandled or there is
* nowhere to go
* @example
* stepIndex(0, 'ArrowRight', 4) // => 1
* stepIndex(3, 'ArrowRight', 4) // => null, the ends do not wrap
* stepIndex(2, 'Home', 4) // => 0
*/
export function stepIndex(current, key, length) {
if (length === 0) return null
const to = key === 'ArrowDown' || key === 'ArrowRight' ? current + 1
: key === 'ArrowUp' || key === 'ArrowLeft' ? current - 1
: key === 'Home' ? 0
: key === 'End' ? length - 1
: null
if (to === null || to < 0 || to >= length) return null
return to
}
/**
* Where a type-ahead lands in a list, given what has been typed so far.
*
* Two rules that look like edge cases and are not. Holding or repeating one letter
* cycles the items starting with it - `aaa` is someone pressing `a` three times looking
* for the next "Archive", not an item named "aaa". And a search that is one character
* long starts *after* the focused item, so pressing that letter again moves on, while a
* buffer still being typed starts *at* it, so the match narrows onto the item the
* reader is already on instead of skipping past it.
*
* @param {string[]} labels The items' text, in list order
* @param {number} current Index of the focused item, `-1` for none
* @param {string} buffer What has been typed inside the type-ahead window
* @returns {number|null} Target index, or null if nothing matches
* @example
* const labels = ['Profile', 'Preferences', 'Archive']
* typeAheadIndex(labels, 0, 'a') // => 2
* typeAheadIndex(labels, 0, 'p') // => 1, starts after the focused item
* typeAheadIndex(labels, 1, 'pre') // => 1, a buffer narrows onto it
*/
export function typeAheadIndex(labels, current, buffer) {
if (!buffer) return null
const query = buffer.toLowerCase()
const repeated = query.length > 1 && query.split('').every((c) => c === query[0])
const prefix = repeated ? query[0] : query
const from = prefix.length === 1 ? current + 1 : current
for (let i = 0; i < labels.length; i++) {
const at = (from + i + labels.length) % labels.length
if (labels[at].trim().toLowerCase().startsWith(prefix)) return at
}
return null
}
/**
* Whether a box of `size` starting at `at` is inside a viewport of `limit`.
*
* Both ends, because a panel that runs off the top is as unreachable as one that runs
* off the bottom.
*
* @param {number} at Where the box starts, in viewport coordinates
* @param {number} size
* @param {number} limit The viewport's extent on the same axis
* @returns {boolean}
* @example
* fits(700, 300, 800) // => false, runs off the bottom
* fits(100, 300, 800) // => true
*/
export function fits(at, size, limit) {
return at >= 0 && at + size <= limit
}
/**
* Where a floating panel goes relative to its trigger: under it, or over it when there
* is no room under; and running from the trigger's inline start, or back the other way
* when that would take it off the edge.
*
* The preferred placement wins ties and wins when neither fits, because a panel with
* nowhere good to go should at least land where the reader expects it.
*
* `centred` asks for the panel to sit on the trigger's middle - what a tooltip wants, where
* an edge-aligned bubble points at nothing. It is a preference and not an instruction: a
* trigger near the edge cannot be centred on without the panel hanging off it, so the
* answer falls back to the edge that fits. Off by default, because `align` is spent as a
* CSS keyword and a caller whose stylesheet answers only `start` and `end` must not be
* handed a third value it has no rule for.
*
* Only the inline axis: which side of the trigger the panel goes on is a separate question,
* and this does not change its answer.
*
* @param {DOMRect|object} trigger Rect of the trigger, in viewport coordinates
* @param {{width: number, height: number}} panel Size of the panel
* @param {{width: number, height: number}} viewport
* @param {boolean} rtl Whether the layout runs right to left
* @param {boolean} [centred=false] Prefer the trigger's middle over either of its edges
* @returns {{side: string, align: string}} `side` is `block-end`/`block-start`,
* `align` is `start`/`end`, or `center` when `centred` was asked for and there was room -
* the CSS spelling, since that is where the value is spent
* @example
* const viewport = { width: 1000, height: 800 }
* placeFlyout(button.getBoundingClientRect(), { width: 200, height: 300 }, viewport, false)
* // => { side: 'block-end', align: 'start' } when there is room below
* @example
* placeFlyout(rect, { width: 200, height: 300 }, viewport, false, true)
* // => { side: 'block-end', align: 'center' } when the middle has room for it
*/
export function placeFlyout(trigger, panel, viewport, rtl, centred) {
const below = fits(trigger.bottom, panel.height, viewport.height)
const above = fits(trigger.top - panel.height, panel.height, viewport.height)
const side = below || !above ? 'block-end' : 'block-start'
// Written from `left` and `right` rather than from `width`, so a caller passing a plain
// object needs no field the edge cases here do not already use.
const middle = trigger.left + (trigger.right - trigger.left - panel.width) / 2
if (centred && fits(middle, panel.width, viewport.width)) return { side, align: 'center' }
// Aligned to the trigger's inline start means its left edge in LTR and its right in
// RTL, so the sums are written in physical terms and the direction picks the edge.
const start = rtl ? trigger.right - panel.width : trigger.left
const end = rtl ? trigger.left : trigger.right - panel.width
return {
side,
align: fits(start, panel.width, viewport.width) || !fits(end, panel.width, viewport.width)
? 'start'
: 'end'
}
}
/**
* Where a nested panel goes: beside the item that opens it, on the inline end unless
* the edge is there, and running down from the item unless the bottom is.
*
* Which is how a submenu near the bottom right corner ends up opening up and to the
* left - one decision per axis rather than a list of corners.
*
* @param {DOMRect|object} item Rect of the item that opens it, in viewport coordinates
* @param {{width: number, height: number}} panel
* @param {{width: number, height: number}} viewport
* @param {boolean} rtl Whether the layout runs right to left
* @returns {{side: string, align: string}} `side` is `inline-end`/`inline-start`,
* `align` is `start`/`end`
* @example
* placeSubmenu(item.getBoundingClientRect(), { width: 200, height: 300 }, viewport, false)
* // => { side: 'inline-end', align: 'start' } when there is room beside it
*/
export function placeSubmenu(item, panel, viewport, rtl) {
const inlineEnd = rtl ? item.left - panel.width : item.right
const inlineStart = rtl ? item.right : item.left - panel.width
const down = fits(item.top, panel.height, viewport.height)
const up = fits(item.bottom - panel.height, panel.height, viewport.height)
return {
side: fits(inlineEnd, panel.width, viewport.width) || !fits(inlineStart, panel.width, viewport.width)
? 'inline-end'
: 'inline-start',
align: down || !up ? 'start' : 'end'
}
}