import { supportsHoverInteractions } from "./utils/pointer"; import { prefersReducedMotion } from "./shared/useReducedMotion"; export type QuantityConstraints = { min: number; max: number | null; step: number; }; const parseFinite = (raw: string | null): number | null => { if (raw === null || raw.trim() === "") return null; const value = Number(raw); return Number.isFinite(value) ? value : null; }; // toFixed() throws above 100 digits; 20 comfortably covers any step Woo // filters realistically emit (including 1e-7) while staying inside the limit. const MAX_PRECISION = 20; /** * Number of decimal places needed to represent `value` exactly, including * scientific-notation forms such as 1e-7 (=> 7) or 1.5e-3 (=> 4). */ const decimalPlaces = (value: number | null): number => { if (value === null || !Number.isFinite(value)) return 0; const text = String(Math.abs(value)); const [mantissa, exponentText] = text.split("e"); const exponent = exponentText ? Number(exponentText) : 0; const dot = mantissa.indexOf("."); const mantissaDecimals = dot === -1 ? 0 : mantissa.length - dot - 1; return Math.min(MAX_PRECISION, Math.max(0, mantissaDecimals - exponent)); }; /** * Read the LIVE native constraints from the quantity input. WooCommerce (and * the server-side quantity limits) rewrite min/max/step after a variation is * selected, so these must be read on every click, never cached at init. * * - min: native attribute when finite (0 is valid), else 1. * - max: native attribute when finite, else unbounded. * - step: native attribute when finite and > 0, else 1. */ export const readQuantityConstraints = ( input: HTMLInputElement, ): QuantityConstraints => { const min = parseFinite(input.getAttribute("min")); const max = parseFinite(input.getAttribute("max")); const step = parseFinite(input.getAttribute("step")); return { min: min ?? 1, max, step: step !== null && step > 0 ? step : 1, }; }; /** * Compute the next quantity for a +/- click using the live native constraints. * Returns null when the input must not change (disabled / readonly). * * Invalid or empty current values fall back to the minimum before stepping. * The result always sits on the native min + n*step grid and never exceeds * max; an off-grid max is never selected (min 1 / max 2.5 / step 1 tops out * at 2). Off-grid current values move to the next grid value in the click * direction, like the native spinner. When max is below min (for example an * out-of-stock variation with max=0) max wins so the stock limit is never * exceeded. */ export const stepQuantity = ( input: HTMLInputElement, direction: 1 | -1, ): number | null => { if (input.disabled || input.readOnly) return null; const { min, max, step } = readQuantityConstraints(input); const current = parseFinite(input.value); const base = current === null ? min : current; // Native number inputs anchor the step grid at min: valid values are // min + n*step (n >= 0). Work in grid indices so the result is always on // the grid, exactly like the native spinner. A small epsilon absorbs // floating-point noise from decimal/scientific steps. const EPSILON = 1e-9; const index = (base - min) / step; let n = direction > 0 ? Math.floor(index + EPSILON) + 1 : Math.ceil(index - EPSILON) - 1; if (n < 0) n = 0; if (max !== null) { // Largest grid index that does not exceed max (a fractional or off-grid // max is never chosen: min 3 / max 6 / step 2 tops out at 5). const maxIndex = Math.floor((max - min) / step + EPSILON); if (maxIndex < 0) { // No grid value fits (e.g. out-of-stock max below min): the stock // limit wins so the value can never exceed it. return max; } if (n > maxIndex) n = maxIndex; } const precision = Math.min( MAX_PRECISION, Math.max(decimalPlaces(step), decimalPlaces(min)), ); return Number((min + n * step).toFixed(precision)); }; /* -------------------------------------------------------------------------- */ /* Variation lifecycle: payload step sync */ /* -------------------------------------------------------------------------- */ const STEP_SYNC_NAMESPACE = "b3wvsqtystep"; const STEP_SYNC_FOUND_EVENT = `found_variation.${STEP_SYNC_NAMESPACE}`; const STEP_SYNC_RESET_EVENTS = `reset_data.${STEP_SYNC_NAMESPACE} hide_variation.${STEP_SYNC_NAMESPACE}`; const STEP_SYNC_ALL_EVENTS = `${STEP_SYNC_FOUND_EVENT} ${STEP_SYNC_RESET_EVENTS}`; const VARIATION_FORM_SELECTOR = "form.variations_form"; const QUANTITY_INPUT_SELECTOR = 'input.qty, input[type="number"].qty'; /** data-* key holding the server-rendered step ("" when none was rendered). */ const DATA_ORIGINAL_STEP = "b3wvsOriginalStep"; type JQueryLike = (el: unknown) => { on: ( events: string, selector: string, handler: (this: HTMLElement, ...args: unknown[]) => void, ) => unknown; off: (events: string) => unknown; }; /** Read the licensed payload step: a positive finite integer, else null. */ export const readPayloadQuantityStep = (variation: unknown): number | null => { if (!variation || typeof variation !== "object") return null; const raw = (variation as { b3wvs_quantity_step?: unknown }) .b3wvs_quantity_step; const value = typeof raw === "string" ? Number(raw) : raw; if (typeof value !== "number" || !Number.isFinite(value)) return null; if (value <= 0 || !Number.isInteger(value)) return null; return value; }; /** * Apply the payload step to every quantity input in the form, remembering the * ORIGINAL server-rendered step once so reset can restore it exactly. Min/max * are owned by WooCommerce (native min_qty/max_qty) and are never touched. */ export const applyVariationQuantityStep = ( form: HTMLElement, variation: unknown, ): void => { const step = readPayloadQuantityStep(variation); form .querySelectorAll(QUANTITY_INPUT_SELECTOR) .forEach((input) => { if (step === null) { // Payload carries no custom step: fall back to the rendered step. restoreOriginalQuantityStep(input); return; } if (input.dataset[DATA_ORIGINAL_STEP] === undefined) { input.dataset[DATA_ORIGINAL_STEP] = input.getAttribute("step") ?? ""; } input.setAttribute("step", String(step)); }); }; const restoreOriginalQuantityStep = (input: HTMLInputElement): void => { const original = input.dataset[DATA_ORIGINAL_STEP]; if (original === undefined) return; if (original === "") { input.removeAttribute("step"); } else { input.setAttribute("step", original); } delete input.dataset[DATA_ORIGINAL_STEP]; }; /** Restore the server-rendered step on every quantity input in the form. */ export const restoreVariationQuantityStep = (form: HTMLElement): void => { form .querySelectorAll(QUANTITY_INPUT_SELECTOR) .forEach(restoreOriginalQuantityStep); }; /** * Bind the variation lifecycle listeners that keep the native `step` in sync * with the licensed payload. Independent of the custom +/- buttons: the * native input (and any theme quantity UI) must honor the step too. * * Delegated on document.body with a private jQuery namespace, so it survives * page-builder re-renders of the form, and re-binding is idempotent: the * namespace is unbound first, so repeated init calls never stack handlers. * Delegated body handlers run after WooCommerce's own form-bound handlers; * Woo never writes `step`, so ordering cannot conflict either way. */ export const initQuantityStepSync = (): void => { const jq = typeof window !== "undefined" ? (window as { jQuery?: unknown }).jQuery : undefined; if (typeof jq !== "function") return; const $ = jq as JQueryLike; const body = $(document.body); body.off(STEP_SYNC_ALL_EVENTS); body.on( STEP_SYNC_FOUND_EVENT, VARIATION_FORM_SELECTOR, function (this: HTMLElement, _event: unknown, variation: unknown) { applyVariationQuantityStep(this, variation); }, ); body.on( STEP_SYNC_RESET_EVENTS, VARIATION_FORM_SELECTOR, function (this: HTMLElement) { restoreVariationQuantityStep(this); }, ); }; /** Unbind the step-sync listeners (tests / teardown). */ export const teardownQuantityStepSync = (): void => { const jq = typeof window !== "undefined" ? (window as { jQuery?: unknown }).jQuery : undefined; if (typeof jq !== "function") return; (($) => $(document.body).off(STEP_SYNC_ALL_EVENTS))(jq as JQueryLike); }; /* -------------------------------------------------------------------------- */ /* Quantity button animation lifecycle */ /* -------------------------------------------------------------------------- */ /** * Class contract (mirrored by QuantityCssEmitter.php and quantity-motion.css): * * - `b3-wvs-animate-quantity-{type}` (plus) / `…-{type}-minus` (minus) means * "the selected effect is active". CSS keys on the class alone (no :hover), * so a keyboard click with no pointer hover animates too. Keyframe modes play * while the class is present; `scale` is a persistent transform. * - `b3-wvs-animate-quantity-click` is a short transient "press" keyframe used * only for the `scale` mode, whose base class cannot be replayed. * * Hover adds the type class on enter and removes it on leave. Click replays the * type class (remove -> reflow -> add) and then clears it after a bounded * window UNLESS the pointer is still hovering with hover enabled, so a click * never disables a later hover. All timers are per element and cleared on * every retrigger. */ export const QUANTITY_CLICK_PRESS_CLASS = "b3-wvs-animate-quantity-click"; /** Longest keyframe (800ms) plus a small buffer. */ export const QUANTITY_CLICK_REPLAY_MS = 850; /** Press keyframe (150ms) plus a small buffer. */ export const QUANTITY_CLICK_PRESS_MS = 200; type QuantityAnimationState = { hovered: boolean; timer: number | null; transientClass: string | null; }; const quantityAnimationStates = new WeakMap(); const getAnimationState = (el: Element): QuantityAnimationState => { let state = quantityAnimationStates.get(el); if (!state) { state = { hovered: false, timer: null, transientClass: null }; quantityAnimationStates.set(el, state); } return state; }; export const quantityAnimationClassFor = ( type: string, direction: "plus" | "minus", ): string => direction === "minus" ? `b3-wvs-animate-quantity-${type}-minus` : `b3-wvs-animate-quantity-${type}`; type QuantityAnimationOptions = { type: string; direction: "plus" | "minus"; animateOnHover: boolean; animateOnClick: boolean; hoverCapable: boolean; /** Optional teardown: aborting removes the hover listeners (React/preview use). */ signal?: AbortSignal; }; /** * Wire hover/click animation state onto a quantity button. Returns the click * trigger the button's click handler calls after the value update. Reduced * motion is read live (per event) so a preference change mid-session is * honored without a re-init; the emitted CSS also suppresses animation and * transform under prefers-reduced-motion as a defense in depth. */ export const bindQuantityButtonAnimation = ( el: HTMLElement, options: QuantityAnimationOptions, ): (() => void) => { const { type, direction, animateOnHover, animateOnClick, hoverCapable, signal, } = options; if (!type || type === "none") return () => {}; const listenerOptions: AddEventListenerOptions | undefined = signal ? { signal } : undefined; const hoverClass = quantityAnimationClassFor(type, direction); const isScale = type === "scale"; const clickClass = isScale ? QUANTITY_CLICK_PRESS_CLASS : hoverClass; const clickDuration = isScale ? QUANTITY_CLICK_PRESS_MS : QUANTITY_CLICK_REPLAY_MS; const hoverActive = animateOnHover && hoverCapable; const clearTimer = (state: QuantityAnimationState) => { if (state.timer !== null) { window.clearTimeout(state.timer); state.timer = null; } state.transientClass = null; }; if (hoverActive) { el.addEventListener( "mouseenter", () => { const state = getAnimationState(el); state.hovered = true; if (prefersReducedMotion()) return; // No-op when a click replay is already running: adding an existing // class never restarts a CSS animation, so the replay finishes untouched. el.classList.add(hoverClass); }, listenerOptions, ); el.addEventListener( "mouseleave", () => { const state = getAnimationState(el); state.hovered = false; // Let a pending click replay of the same class finish; its timer will // remove the class because we are no longer hovered. if (state.timer !== null && state.transientClass === hoverClass) return; el.classList.remove(hoverClass); }, listenerOptions, ); } if (!animateOnClick) return () => {}; return () => { if (prefersReducedMotion()) return; const state = getAnimationState(el); clearTimer(state); // Restart the animation even if the class is already applied (hover). el.classList.remove(clickClass); // Force reflow so the browser treats the next add() as a new animation. void el.offsetWidth; el.classList.add(clickClass); state.transientClass = clickClass; state.timer = window.setTimeout(() => { state.timer = null; state.transientClass = null; // A persistent hover class must survive the click window. if (clickClass === hoverClass && hoverActive && state.hovered) return; el.classList.remove(clickClass); }, clickDuration); }; }; /** * Initialize custom quantity +/- buttons for WooCommerce quantity inputs */ export const initQuantityButtons = () => { const data = typeof b3WvsData !== "undefined" ? b3WvsData : null; const raw = (data?.settings ?? {}) as Record; const settings = { quantityCustomButtons: raw.quantity_custom_buttons === true, quantityAnimationType: String(raw.quantity_animation_type ?? "none"), quantityAnimateOnHover: raw.quantity_animate_on_hover !== false, quantityAnimateOnClick: raw.quantity_animate_on_click === true, quantityStyle: String(raw.quantity_style ?? "default"), quantityButtonPosition: String( raw.quantity_button_position ?? "both-sides", ), }; // Custom quantity limits are independent of the +/- buttons, so the step // sync binds regardless of the button setting/style below. initQuantityStepSync(); const removeInjectedQuantityButtons = (root: ParentNode = document) => { root .querySelectorAll(".b3-wvs-qty-minus, .b3-wvs-qty-plus") .forEach((button) => button.remove()); }; if (!settings.quantityCustomButtons || settings.quantityStyle === "default") { removeInjectedQuantityButtons(); return; } const quantityInputs = document.querySelectorAll( 'input.qty, input[type="number"].qty', ); const canUseHoverAnimations = supportsHoverInteractions(); const animationType = String(settings.quantityAnimationType || "none"); quantityInputs.forEach((input) => { const parent = input.closest(".quantity"); if (!parent) return; // Avoid duplicate buttons if ( parent.querySelector(".b3-wvs-qty-minus") || parent.querySelector(".b3-wvs-qty-plus") ) { return; } // Constraints are intentionally NOT captured here: see stepQuantity(). // Create minus button const minusBtn = document.createElement("button"); minusBtn.type = "button"; minusBtn.className = "minus b3-wvs-qty-minus"; minusBtn.innerHTML = "−"; minusBtn.setAttribute("aria-label", "Decrease quantity"); // Create plus button const plusBtn = document.createElement("button"); plusBtn.type = "button"; plusBtn.className = "plus b3-wvs-qty-plus"; plusBtn.innerHTML = "+"; plusBtn.setAttribute("aria-label", "Increase quantity"); // Every advertised mode (including scale) gets the same hover/click // lifecycle; mode-specific differences live in the CSS contract. const animationOptions = { type: animationType, animateOnHover: settings.quantityAnimateOnHover !== false, animateOnClick: Boolean(settings.quantityAnimateOnClick), hoverCapable: canUseHoverAnimations, } as const; const playMinusClick = bindQuantityButtonAnimation(minusBtn, { ...animationOptions, direction: "minus", }); const playPlusClick = bindQuantityButtonAnimation(plusBtn, { ...animationOptions, direction: "plus", }); // Handle minus button click minusBtn.addEventListener("click", (e) => { e.preventDefault(); const newValue = stepQuantity(input, -1); if (newValue === null) return; input.value = newValue.toString(); input.dispatchEvent(new Event("change", { bubbles: true })); playMinusClick(); }); // Handle plus button click plusBtn.addEventListener("click", (e) => { e.preventDefault(); const newValue = stepQuantity(input, 1); if (newValue === null) return; input.value = newValue.toString(); input.dispatchEvent(new Event("change", { bubbles: true })); playPlusClick(); }); // Insert buttons based on position setting const buttonPosition = settings.quantityButtonPosition; if (buttonPosition === "left") { parent.insertBefore(minusBtn, input); parent.insertBefore(plusBtn, input); } else if (buttonPosition === "right") { parent.appendChild(minusBtn); parent.appendChild(plusBtn); } else { // both-sides (default) parent.insertBefore(minusBtn, input); parent.appendChild(plusBtn); } }); };