mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
547 lines
21 KiB
JavaScript
547 lines
21 KiB
JavaScript
/**
|
|
* Split-flap (departure-board) text animation for the status chips.
|
|
*
|
|
* When a chip's label changes — "LOADING LIVE DATA" → "LOAD COMPLETE",
|
|
* "loading frames" → "camera grid ready" — the characters flip over
|
|
* mechanically, left to right, like a Solari board at a ferry terminal.
|
|
*
|
|
* FOUR INVARIANTS HOLD THIS TOGETHER — do not "fix" any of them:
|
|
*
|
|
* 1. DOM TEXT IS THE TRUTH, AND ITS NODE NEVER MOVES. The first call upgrades
|
|
* a chip label into a permanent two-child shell: a `.gev-flap-text` span
|
|
* holding one long-lived `Text` node, and an `aria-hidden`
|
|
* `.gev-flap-cells` sibling. After that the ONLY text operation for the
|
|
* life of the chip is `node.data = next` — a single `characterData`
|
|
* mutation. Nothing is ever reparented, so the label is never
|
|
* transiently empty and the `aria-live` region never sees a removal /
|
|
* reinsertion pair it could announce twice. `element.textContent` is the
|
|
* settled string at every instant, because the cells carry NO text at all:
|
|
* both glyphs are CSS generated content (`::before` from `data-flap-prev`,
|
|
* `::after` from `data-flap-next`), which never reaches `textContent`.
|
|
* QA pins and the traffic chip's own `textContent !==` guard in ui.js
|
|
* therefore always read the truth; the flaps are purely visual.
|
|
*
|
|
* 2. NO ANIMATION LOOP, AND EXACTLY ONE TIMER PER CHANGE. The motion is CSS
|
|
* `animation`/`transition` only, triggered once per text change and
|
|
* staggered via a per-cell `--gev-flap-delay` custom property. A change
|
|
* schedules exactly ONE `setTimeout` — the settle that strips the cells.
|
|
* The width ease ends on a `transitionend`/`transitioncancel` listener,
|
|
* not a second timer. There is zero per-frame JS and zero periodic work;
|
|
* idle cost is nothing, and nothing here requests a Cesium render — the
|
|
* chips are DOM, outside the panel-stack observers, so the render governor
|
|
* is untouched.
|
|
*
|
|
* 3. ONLY WHAT WAS VISIBLE FLAPS AWAY. An interrupted cascade (A→B cut short
|
|
* by C) derives each column's outgoing glyph from what that column is
|
|
* actually SHOWING at that instant — which for a column whose stagger has
|
|
* not elapsed yet is still A, not the pending B. Deriving it from the
|
|
* pending target would flash a glyph that was never on screen.
|
|
*
|
|
* 4. COLUMNS NEVER RENUMBER MID-CASCADE. For the whole cascade the board keeps
|
|
* one column per index of the LONGER string, each holding its own width.
|
|
* A column the new string does not reach flaps its old glyph to a BLANK in
|
|
* place — exactly how a departure board clears a cell — rather than
|
|
* collapsing to zero width. Collapsing would stack the absolutely-positioned
|
|
* outgoing glyphs on top of each other and would let a later glyph slide
|
|
* into an earlier column, which in turn would make invariant 3 lie. The
|
|
* container only takes up the slack AFTER every flap has landed, as one
|
|
* smooth width ease.
|
|
*
|
|
* The motion vocabulary deliberately matches the cockpit odometer roll
|
|
* (`setCockpitRollingValue`, style.css `.cockpit-roll-token`): ~200 ms per
|
|
* character on `cubic-bezier(0.2, 0.75, 0.25, 1)`, reduced-motion collapsing
|
|
* the duration rather than removing the animation. What this adds over the
|
|
* odometer is the CASCADE — the left-to-right sweep that makes a departure
|
|
* board read as a departure board.
|
|
*/
|
|
|
|
/**
|
|
* One-line kill switch. Flip to `false` and every chip goes back to a plain
|
|
* instant text swap with no other code change.
|
|
*/
|
|
export const SPLIT_FLAP_ENABLED = true;
|
|
|
|
/** Time one character spends flipping. */
|
|
export const FLAP_CHAR_MS = 190;
|
|
/** Nominal gap between consecutive characters starting their flip. */
|
|
export const FLAP_STAGGER_MS = 26;
|
|
/**
|
|
* Hard ceiling for a whole cascade. Long labels compress their stagger to
|
|
* fit rather than running on — a chip that flaps for a full second reads as
|
|
* a slot machine, not a quiet mechanical settle.
|
|
*/
|
|
export const FLAP_MAX_TOTAL_MS = 620;
|
|
/** Slack after the last character lands before the cells are stripped. */
|
|
export const FLAP_SETTLE_SLACK_MS = 60;
|
|
/**
|
|
* Fraction of a character's flip at which the outgoing glyph has rotated
|
|
* away and the incoming one takes over as what the eye reads. MUST track the
|
|
* `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css — it is what
|
|
* makes an interrupted cascade pick honest outgoing glyphs.
|
|
*/
|
|
export const FLAP_TURN_RATIO = 0.5;
|
|
|
|
const HOST_CLASS = 'gev-flap-host';
|
|
const ACTIVE_CLASS = 'gev-flap-active';
|
|
const TEXT_CLASS = 'gev-flap-text';
|
|
const CELLS_CLASS = 'gev-flap-cells';
|
|
const CELL_CLASS = 'gev-flap-cell';
|
|
const FLAPPING_CLASS = 'is-flapping';
|
|
const SIZING_CLASS = 'gev-flap-sizing';
|
|
|
|
/**
|
|
* What a reserved-but-empty column shows. A column the new string does not
|
|
* reach is not removed mid-cascade — it flaps to a blank and holds its place,
|
|
* which is both what a real board does and what keeps column indices stable.
|
|
*/
|
|
const BLANK = ' ';
|
|
|
|
/** In-flight cascade state, keyed by element so nothing is stored on the node. */
|
|
const flapStates = new WeakMap();
|
|
/** Teardown for an in-flight width ease (listeners, not timers). */
|
|
const widthEases = new WeakMap();
|
|
|
|
function positiveNumber(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
}
|
|
|
|
function nowMs() {
|
|
return typeof performance !== 'undefined' &&
|
|
typeof performance.now === 'function'
|
|
? performance.now()
|
|
: Date.now();
|
|
}
|
|
|
|
/**
|
|
* Diff two strings into per-character flap cells with staggered delays.
|
|
*
|
|
* Unchanged characters do not animate — a real split-flap cell that already
|
|
* shows the right glyph simply does not move, which is both authentic and
|
|
* free. The stagger is rebased on the FIRST changed column so a label whose
|
|
* head is stable ("LOAD COMPLETE" keeps "LOAD" from "LOADING LIVE DATA")
|
|
* starts flipping immediately instead of idling through untouched columns.
|
|
*
|
|
* Pure — no DOM, no clock. This is the unit-tested core.
|
|
*
|
|
* @param {string} fromText - Glyphs currently on screen.
|
|
* @param {string} toText - Text to settle on.
|
|
* @param {{charMs?: number, staggerMs?: number, maxTotalMs?: number}} [options]
|
|
* @returns {{
|
|
* cells: Array<{index: number, from: string, to: string, changed: boolean,
|
|
* vacating: boolean, delayMs: number}>,
|
|
* durationMs: number, staggerMs: number, changedCount: number,
|
|
* firstChanged: number, lastChanged: number,
|
|
* }}
|
|
*/
|
|
export function planSplitFlap(fromText, toText, options = {}) {
|
|
const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
|
|
const baseStagger = positiveNumber(options.staggerMs, FLAP_STAGGER_MS);
|
|
const maxTotalMs = positiveNumber(options.maxTotalMs, FLAP_MAX_TOTAL_MS);
|
|
|
|
// Code-point safe: a chip label may carry a separator like '·'.
|
|
const fromChars = Array.from(String(fromText ?? ''));
|
|
const toChars = Array.from(String(toText ?? ''));
|
|
const width = Math.max(fromChars.length, toChars.length);
|
|
|
|
let firstChanged = -1;
|
|
let lastChanged = -1;
|
|
for (let index = 0; index < width; index += 1) {
|
|
if ((fromChars[index] ?? '') !== (toChars[index] ?? '')) {
|
|
if (firstChanged < 0) firstChanged = index;
|
|
lastChanged = index;
|
|
}
|
|
}
|
|
|
|
if (firstChanged < 0) {
|
|
return {
|
|
cells: [],
|
|
durationMs: 0,
|
|
staggerMs: 0,
|
|
changedCount: 0,
|
|
firstChanged: -1,
|
|
lastChanged: -1,
|
|
};
|
|
}
|
|
|
|
// Compress the stagger so the whole cascade fits the budget.
|
|
const span = lastChanged - firstChanged + 1;
|
|
const room = Math.max(0, maxTotalMs - charMs);
|
|
const staggerMs = span > 1 ? Math.min(baseStagger, room / (span - 1)) : 0;
|
|
|
|
const cells = [];
|
|
let changedCount = 0;
|
|
for (let index = 0; index < width; index += 1) {
|
|
const from = fromChars[index] ?? '';
|
|
const to = toChars[index] ?? '';
|
|
const changed = from !== to;
|
|
if (changed) changedCount += 1;
|
|
cells.push({
|
|
index,
|
|
from,
|
|
to,
|
|
changed,
|
|
// The string shrank: this column has an old glyph to flap away and no
|
|
// new one, so it flaps to a blank and holds its place (invariant 4).
|
|
vacating: changed && to === '' && from !== '',
|
|
delayMs: changed ? Math.round((index - firstChanged) * staggerMs) : 0,
|
|
});
|
|
}
|
|
|
|
return {
|
|
cells,
|
|
durationMs: Math.round((lastChanged - firstChanged) * staggerMs + charMs),
|
|
staggerMs,
|
|
changedCount,
|
|
firstChanged,
|
|
lastChanged,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What one column is showing, before or after it turns over.
|
|
*
|
|
* A column with no glyph on the side being asked for is a RESERVED BLANK, not
|
|
* an absence: it is still on the board, holding its width and its index. That
|
|
* is what keeps the returned string positionally true.
|
|
*/
|
|
function displayedGlyph(cell, turned) {
|
|
if (!cell.changed) return cell.to;
|
|
return (turned ? cell.to : cell.from) || BLANK;
|
|
}
|
|
|
|
/**
|
|
* The glyphs a cascade is actually SHOWING at `elapsedMs`.
|
|
*
|
|
* A column that has not reached its stagger delay yet still displays its old
|
|
* glyph; one past the halfway turn displays its new one; unchanged columns
|
|
* never moved. This is what an interrupting change must flap away from, so
|
|
* no column ever shows a glyph that was never visually current.
|
|
*
|
|
* The result is positionally true by construction: it always has exactly one
|
|
* character per column, so index N of the string is always column N of the
|
|
* board. A cleared or not-yet-filled column reads as a blank rather than
|
|
* closing up — otherwise a later glyph would appear to occupy an earlier
|
|
* column and an interrupt would flap the wrong glyph away.
|
|
*
|
|
* Pure — no DOM.
|
|
*
|
|
* @param {{cells: Array<object>}} plan - A plan from `planSplitFlap`.
|
|
* @param {number} elapsedMs - Milliseconds since the cascade started.
|
|
* @param {{charMs?: number, turnRatio?: number}} [options]
|
|
* @returns {string} The currently displayed string, one character per column.
|
|
*/
|
|
export function visibleGlyphs(plan, elapsedMs, options = {}) {
|
|
const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
|
|
const turnRatio = Number.isFinite(Number(options.turnRatio))
|
|
? Number(options.turnRatio)
|
|
: FLAP_TURN_RATIO;
|
|
const elapsed = Number.isFinite(Number(elapsedMs))
|
|
? Math.max(0, Number(elapsedMs))
|
|
: 0;
|
|
const turn = charMs * turnRatio;
|
|
let out = '';
|
|
for (const cell of plan?.cells || []) {
|
|
out += displayedGlyph(cell, elapsed >= cell.delayMs + turn);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Whether the viewer asked for reduced motion. */
|
|
function prefersReducedMotion() {
|
|
return (
|
|
typeof window !== 'undefined' &&
|
|
typeof window.matchMedia === 'function' &&
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether an element is actually on screen right now.
|
|
*
|
|
* `getClientRects()` alone is not enough: clean-UI and recording mode hide the
|
|
* chips with `opacity: 0 !important; visibility: hidden !important` on the
|
|
* chip CONTAINER, and the traffic/CCTV chips sit at `opacity: 0` until they
|
|
* get `.visible`. All of those still report client rects, so a naive check
|
|
* would animate a chip nobody can see. `checkVisibility` walks ancestors for
|
|
* exactly these properties; the fallback walks them by hand.
|
|
*/
|
|
function isVisible(element) {
|
|
if (!element?.isConnected) return false;
|
|
if (typeof element.checkVisibility === 'function') {
|
|
return element.checkVisibility({
|
|
opacityProperty: true,
|
|
visibilityProperty: true,
|
|
contentVisibilityAuto: true,
|
|
});
|
|
}
|
|
if (typeof element.getClientRects !== 'function') return false;
|
|
if (element.getClientRects().length === 0) return false;
|
|
const view = element.ownerDocument?.defaultView;
|
|
if (typeof view?.getComputedStyle !== 'function') return true;
|
|
for (
|
|
let node = element;
|
|
node && node.nodeType === 1;
|
|
node = node.parentElement
|
|
) {
|
|
const style = view.getComputedStyle(node);
|
|
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
|
if (Number(style.opacity) === 0) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function measureWidth(element) {
|
|
return typeof element.getBoundingClientRect === 'function'
|
|
? element.getBoundingClientRect().width
|
|
: 0;
|
|
}
|
|
|
|
/**
|
|
* The chip label's permanent shell.
|
|
*
|
|
* Built once per element and then reused forever: the `Text` node inside
|
|
* `.gev-flap-text` is the same object for the life of the chip, so a label
|
|
* change never reparents anything (invariant 1). Rebuilt only if something
|
|
* outside this module has clobbered the label's children.
|
|
*/
|
|
function ensureHost(element) {
|
|
// O(1) happy path: this runs on every chip tick, including the ones where
|
|
// nothing changed.
|
|
const text = element.firstElementChild;
|
|
const cells = text?.nextElementSibling;
|
|
if (
|
|
text?.classList?.contains(TEXT_CLASS) &&
|
|
text.firstChild?.nodeType === 3 &&
|
|
cells?.classList?.contains(CELLS_CLASS) &&
|
|
!cells.nextElementSibling
|
|
) {
|
|
return { text, cells };
|
|
}
|
|
|
|
const doc = element.ownerDocument || globalThis.document;
|
|
const carried = element.textContent ?? '';
|
|
const nextText = doc.createElement('span');
|
|
nextText.className = TEXT_CLASS;
|
|
nextText.append(doc.createTextNode(carried));
|
|
const nextCells = doc.createElement('span');
|
|
nextCells.className = CELLS_CLASS;
|
|
// Decorative only. Assistive tech ignores this subtree entirely, which is
|
|
// why rebuilding it every change cannot disturb the aria-live region.
|
|
nextCells.setAttribute('aria-hidden', 'true');
|
|
element.classList.add(HOST_CLASS);
|
|
element.replaceChildren(nextText, nextCells);
|
|
return { text: nextText, cells: nextCells };
|
|
}
|
|
|
|
/** Drop the width transition, leaving the element at its natural width. */
|
|
function clearSizing(element) {
|
|
element.classList?.remove(SIZING_CLASS);
|
|
element.style?.removeProperty('width');
|
|
element.style?.removeProperty('--gev-flap-total');
|
|
}
|
|
|
|
function cancelWidthEase(element) {
|
|
const teardown = widthEases.get(element);
|
|
if (!teardown) return;
|
|
widthEases.delete(element);
|
|
teardown();
|
|
}
|
|
|
|
/**
|
|
* Ease the element between two measured widths.
|
|
*
|
|
* Called twice per change, and exactly one of the two ever has work to do: a
|
|
* GROWING label reserves its new columns the moment the cells go in, so the
|
|
* ease happens at the start; a SHRINKING label holds every column for the
|
|
* whole cascade, so the slack is only taken up once the flaps have landed.
|
|
*
|
|
* The end of the ease is a `transitionend` listener rather than a timer, so a
|
|
* change still schedules exactly one `setTimeout` (invariant 2). A transition
|
|
* that never fires is harmless: the pinned width equals the natural width it
|
|
* was easing to, and the next change clears it regardless.
|
|
*/
|
|
function easeWidth(element, fromWidth, toWidth, durationMs) {
|
|
cancelWidthEase(element);
|
|
if (!(fromWidth > 0.5) || Math.abs(toWidth - fromWidth) <= 0.5) {
|
|
clearSizing(element);
|
|
return false;
|
|
}
|
|
element.style.setProperty('--gev-flap-total', `${durationMs}ms`);
|
|
element.style.width = `${fromWidth}px`;
|
|
element.classList.add(SIZING_CLASS);
|
|
void element.offsetWidth; // flush the start value so the transition runs
|
|
element.style.width = `${toWidth}px`;
|
|
|
|
const finish = (event) => {
|
|
if (event && (event.target !== element || event.propertyName !== 'width'))
|
|
return;
|
|
cancelWidthEase(element);
|
|
clearSizing(element);
|
|
};
|
|
const teardown = () => {
|
|
element.removeEventListener('transitionend', finish);
|
|
element.removeEventListener('transitioncancel', finish);
|
|
};
|
|
element.addEventListener('transitionend', finish);
|
|
element.addEventListener('transitioncancel', finish);
|
|
widthEases.set(element, teardown);
|
|
return true;
|
|
}
|
|
|
|
function clearFlapTimer(element) {
|
|
const state = flapStates.get(element);
|
|
if (!state) return;
|
|
clearTimeout(state.timer);
|
|
flapStates.delete(element);
|
|
}
|
|
|
|
/**
|
|
* Put the label back in its resting state: real text visible, no cells.
|
|
*
|
|
* The `Text` node is untouched apart from its data, and neither permanent span
|
|
* is removed — only the decorative cells are emptied.
|
|
*/
|
|
function rest(element, host) {
|
|
element.classList.remove(ACTIVE_CLASS);
|
|
host.cells.replaceChildren();
|
|
element.style?.removeProperty('--gev-flap-dur');
|
|
// The real text is the accessible name; a stale aria-label from an earlier
|
|
// render would otherwise mask it.
|
|
element.removeAttribute?.('aria-label');
|
|
}
|
|
|
|
/**
|
|
* Strip the cells once the cascade lands, then take up any width slack.
|
|
*
|
|
* A shrinking label has been holding every column at full width for the whole
|
|
* cascade (invariant 4), so this is where the board narrows — as one eased
|
|
* transition, never a single-frame snap.
|
|
*/
|
|
function settle(element, expected, easeMs) {
|
|
flapStates.delete(element);
|
|
// A newer label won the race — leave its cells alone.
|
|
if (element.textContent !== expected) return;
|
|
const host = ensureHost(element);
|
|
const cascadeWidth = measureWidth(element);
|
|
cancelWidthEase(element);
|
|
clearSizing(element);
|
|
rest(element, host);
|
|
const naturalWidth = measureWidth(element);
|
|
easeWidth(element, cascadeWidth, naturalWidth, easeMs);
|
|
}
|
|
|
|
/**
|
|
* Set a chip label, flipping the changed characters into place.
|
|
*
|
|
* Safe to call on every tick: the chips are repainted by a 500 ms ticker and
|
|
* a 60 ms one, so an unchanged write must be a no-op or the animation would
|
|
* restart forever. Returns whether a flap was actually started.
|
|
*
|
|
* @param {HTMLElement|null} element - Chip label element.
|
|
* @param {string} text - Text to settle on.
|
|
* @param {{immediate?: boolean, charMs?: number, staggerMs?: number,
|
|
* maxTotalMs?: number}} [options]
|
|
* @returns {boolean} True when a flap animation was started.
|
|
*/
|
|
export function setSplitFlapText(element, text, options = {}) {
|
|
if (!element) return false;
|
|
const next = String(text ?? '');
|
|
// Upgrade to the permanent shell FIRST, so that one-time structural change
|
|
// happens on a tick where the text is not changing. Every subsequent label
|
|
// change is then a lone characterData mutation (invariant 1).
|
|
const host = ensureHost(element);
|
|
// textContent is the SETTLED string even mid-cascade, so this is the right
|
|
// idempotence check against the repeating chip tickers.
|
|
const settled = element.textContent ?? '';
|
|
if (settled === next) return false;
|
|
|
|
// What the viewer can SEE right now. Mid-cascade that is not the settled
|
|
// string: columns whose stagger has not elapsed are still showing the
|
|
// previous label's glyphs, and those are what must flap away.
|
|
const state = flapStates.get(element);
|
|
const displayed = state
|
|
? visibleGlyphs(state.plan, nowMs() - state.startedAt, {
|
|
charMs: state.charMs,
|
|
})
|
|
: settled;
|
|
clearFlapTimer(element);
|
|
|
|
const beforeWidth = measureWidth(element);
|
|
|
|
const animate =
|
|
SPLIT_FLAP_ENABLED &&
|
|
options.immediate !== true &&
|
|
!prefersReducedMotion() &&
|
|
isVisible(element);
|
|
|
|
const plan = animate ? planSplitFlap(displayed, next, options) : null;
|
|
|
|
// THE ONLY TEXT OPERATION. One characterData mutation, no reparenting, so
|
|
// the label is never transiently empty and the live region cannot see a
|
|
// removal/reinsertion pair (invariant 1).
|
|
host.text.firstChild.data = next;
|
|
|
|
if (!plan?.changedCount) {
|
|
cancelWidthEase(element);
|
|
clearSizing(element);
|
|
rest(element, host);
|
|
return false;
|
|
}
|
|
|
|
const doc = element.ownerDocument || globalThis.document;
|
|
const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
|
|
|
|
const cellNodes = [];
|
|
for (const cell of plan.cells) {
|
|
const node = doc.createElement('span');
|
|
node.className = CELL_CLASS;
|
|
// Every column carries an in-flow incoming face, so every column holds a
|
|
// width and its index for the whole cascade (invariant 4). A column the
|
|
// new string does not reach flaps to a blank rather than collapsing.
|
|
node.dataset.flapNext = cell.to || BLANK;
|
|
if (cell.changed) {
|
|
node.classList.add(FLAPPING_CLASS);
|
|
node.dataset.flapPrev = cell.from || BLANK;
|
|
node.style.setProperty('--gev-flap-delay', `${cell.delayMs}ms`);
|
|
}
|
|
cellNodes.push(node);
|
|
}
|
|
|
|
element.removeAttribute('aria-label');
|
|
cancelWidthEase(element);
|
|
clearSizing(element);
|
|
// Only the decorative sibling is rebuilt; the text node is not involved.
|
|
host.cells.replaceChildren(...cellNodes);
|
|
element.classList.add(ACTIVE_CLASS);
|
|
element.style.setProperty('--gev-flap-dur', `${charMs}ms`);
|
|
|
|
// Width, phase one. A GROWING label reserves its new columns the instant the
|
|
// cells go in, so ease from what was on screen to the cascade width. A
|
|
// shrinking one is already at cascade width, so this is a no-op and the ease
|
|
// happens in settle() instead, once the flaps have landed.
|
|
easeWidth(element, beforeWidth, measureWidth(element), plan.durationMs);
|
|
|
|
// The one and only timer this change schedules (invariant 2).
|
|
flapStates.set(element, {
|
|
plan,
|
|
charMs,
|
|
startedAt: nowMs(),
|
|
timer: setTimeout(
|
|
() => settle(element, next, plan.durationMs),
|
|
plan.durationMs + FLAP_SETTLE_SLACK_MS,
|
|
),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/** Stop decoration without replacing the label's permanent accessible text. */
|
|
export function disposeSplitFlap(element) {
|
|
if (!element) return;
|
|
clearFlapTimer(element);
|
|
cancelWidthEase(element);
|
|
clearSizing(element);
|
|
const cells = element.querySelector?.('.gev-flap-cells');
|
|
if (cells) rest(element, { cells });
|
|
}
|