{"version":3,"sources":["../src/index.ts","../../../core/src/events.ts","../../../core/src/index.ts"],"sourcesContent":["/**\n * jQuery adapter for TouchSpin using standalone adapter\n *\n * This adapter wraps the standalone mount API with a jQuery plugin interface,\n * maintaining 100% backward compatibility with the legacy jQuery plugin API.\n */\n\nimport type { TouchSpinCorePublicAPI } from '@touchspin/core';\nimport { TouchSpinCallableEvent } from '@touchspin/core';\nimport type { MountOptions } from '@touchspin/standalone';\n\nexport type { TouchSpinUpdateSettingsData } from '@touchspin/core';\n// Re-export event types for external use\nexport { TouchSpinCallableEvent, TouchSpinEmittedEvent } from '@touchspin/core';\n\n// Minimal jQuery types to avoid hard module dependency\ntype JQueryInst = {\n  each: (cb: (this: Element) => void) => JQueryInst;\n  on: (...args: unknown[]) => JQueryInst;\n  off: (...args: unknown[]) => JQueryInst;\n  trigger: (...args: unknown[]) => JQueryInst;\n};\ntype JQueryStatic = {\n  fn: Record<string, unknown> & { TouchSpin?: (options?: unknown, arg?: unknown) => unknown };\n  (selector: Element | string): JQueryInst;\n};\n\n/**\n * Install jQuery plugin that uses standalone adapter\n * @param $ - jQuery instance\n * @param mountFn - Mount function from standalone adapter (e.g., from @touchspin/standalone/bootstrap5)\n */\nexport function installJQueryAdapter(\n  $: JQueryStatic,\n  mountFn: (host: Element | string, opts?: MountOptions) => TouchSpinCorePublicAPI | null\n) {\n  // Define the plugin with typed params and jQuery context\n  $.fn.TouchSpin = function (this: JQueryInst, options?: unknown, arg?: unknown): unknown {\n    // Command API - forward to instance\n    if (typeof options === 'string') {\n      const cmd = String(options).toLowerCase();\n      let ret: unknown;\n\n      this.each(function (this: Element) {\n        const inputEl = this as HTMLInputElement;\n        const inst = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n\n        // Handle get/getvalue specially - fall back to raw input value if no instance\n        if ((cmd === 'getvalue' || cmd === 'get') && ret === undefined) {\n          if (inst) {\n            ret = inst.getValue();\n          } else {\n            // No TouchSpin instance - return raw input value\n            ret = inputEl.value;\n          }\n          return; // Skip other commands if no instance\n        }\n\n        if (!inst) return; // No instance exists - other commands ignored\n\n        switch (cmd) {\n          case 'destroy':\n            inst.destroy();\n            delete (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n            break;\n          case 'uponce':\n            inst.upOnce();\n            break;\n          case 'downonce':\n            inst.downOnce();\n            break;\n          case 'startupspin':\n            inst.startUpSpin();\n            break;\n          case 'startdownspin':\n            inst.startDownSpin();\n            break;\n          case 'stopspin':\n            inst.stopSpin();\n            break;\n          case 'updatesettings':\n            inst.updateSettings((arg as Record<string, unknown>) || {});\n            break;\n          case 'setvalue':\n          case 'set':\n            inst.setValue(arg as number | string);\n            break;\n        }\n      });\n\n      return ret === undefined ? this : (ret as unknown);\n    }\n\n    // Initialize - use standalone mount\n    return this.each(function (this: Element) {\n      const $input = $(this as Element);\n      const inputEl = this as HTMLInputElement;\n\n      // Try to mount, silently skip non-input elements (backward compatibility)\n      let inst: TouchSpinCorePublicAPI | null = null;\n      try {\n        inst = mountFn(inputEl, (options as MountOptions) || {});\n      } catch (_error) {\n        // Silently skip mount failures (e.g., non-input elements)\n        // This matches legacy jQuery plugin behavior\n        return;\n      }\n\n      // If mount returned null, skip jQuery setup\n      if (!inst) {\n        return;\n      }\n\n      // Store instance on element for command API\n      (inputEl as unknown as { __touchspin: TouchSpinCorePublicAPI }).__touchspin = inst;\n\n      // Define jQuery teardown function that cleans up jQuery-specific resources\n      const jqueryTeardown = () => {\n        // Clean up ONLY the jQuery events that THIS plugin explicitly added\n        // Based on the callable events below: UP_ONCE, DOWN_ONCE, START_UP_SPIN, START_DOWN_SPIN, STOP_SPIN, UPDATE_SETTINGS, DESTROY, blur.touchspin\n        $input.off(\n          `${TouchSpinCallableEvent.UP_ONCE} ${TouchSpinCallableEvent.DOWN_ONCE} ${TouchSpinCallableEvent.START_UP_SPIN} ${TouchSpinCallableEvent.START_DOWN_SPIN} ${TouchSpinCallableEvent.STOP_SPIN} ${TouchSpinCallableEvent.UPDATE_SETTINGS} ${TouchSpinCallableEvent.DESTROY} blur.touchspin`\n        );\n      };\n\n      // Register teardown with core so it's called on core destroy too\n      inst.registerTeardown(jqueryTeardown);\n\n      // Callable events - forward to core (core manages lifecycle)\n      $input.on(TouchSpinCallableEvent.UP_ONCE, () => {\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) api.upOnce();\n      });\n      $input.on(TouchSpinCallableEvent.DOWN_ONCE, () => {\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) api.downOnce();\n      });\n      $input.on(TouchSpinCallableEvent.START_UP_SPIN, () => {\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) api.startUpSpin();\n      });\n      $input.on(TouchSpinCallableEvent.START_DOWN_SPIN, () => {\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) api.startDownSpin();\n      });\n      $input.on(TouchSpinCallableEvent.STOP_SPIN, () => {\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) api.stopSpin();\n      });\n      $input.on(TouchSpinCallableEvent.UPDATE_SETTINGS, (_e: unknown, o: unknown) => {\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) api.updateSettings((o as Record<string, unknown>) || {});\n      });\n      $input.on(TouchSpinCallableEvent.DESTROY, () => {\n        // Forward destroy to core (core will call registered teardown callbacks)\n        const api = (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        if (api) {\n          api.destroy();\n          delete (inputEl as unknown as { __touchspin?: TouchSpinCorePublicAPI }).__touchspin;\n        }\n      });\n\n      // Handle jQuery-triggered blur events for backward compatibility\n      // jQuery's .trigger('blur') doesn't fire native addEventListener('blur')\n      $input.on('blur.touchspin', () => {\n        const core = (inputEl as unknown as Record<string, unknown>)._touchSpinCore as\n          | { _checkValue?: (finalize: boolean) => void }\n          | undefined;\n        core?._checkValue?.(true);\n      });\n    });\n  };\n}\n\n/**\n * Auto-install jQuery adapter if jQuery is available on window\n * @param mountFn - Mount function from standalone adapter\n */\nexport function autoInstall(\n  mountFn: (host: Element | string, opts?: MountOptions) => TouchSpinCorePublicAPI | null\n): void {\n  const $ = (globalThis as unknown as { jQuery?: JQueryStatic }).jQuery;\n  if ($) {\n    installJQueryAdapter($, mountFn);\n  } else {\n    console.warn(\n      'jQuery adapter: jQuery not found on window. Call installJQueryAdapter($, mountFn) manually.'\n    );\n  }\n}\n","/**\n * TouchSpin Event Types\n * Provides type safety for event names used throughout TouchSpin ecosystem\n */\n\n/**\n * Callable events that can be triggered on TouchSpin jQuery instances\n * These events tell TouchSpin to perform an action\n */\nexport enum TouchSpinCallableEvent {\n  /** Update TouchSpin settings */\n  UPDATE_SETTINGS = 'touchspin.updatesettings',\n\n  /** Increment value by one step */\n  UP_ONCE = 'touchspin.uponce',\n\n  /** Decrement value by one step */\n  DOWN_ONCE = 'touchspin.downonce',\n\n  /** Start continuous upward spinning */\n  START_UP_SPIN = 'touchspin.startupspin',\n\n  /** Start continuous downward spinning */\n  START_DOWN_SPIN = 'touchspin.startdownspin',\n\n  /** Stop any continuous spinning */\n  STOP_SPIN = 'touchspin.stopspin',\n\n  /** Destroy the TouchSpin instance */\n  DESTROY = 'touchspin.destroy',\n}\n\n/**\n * Events emitted by TouchSpin during operation\n * These events notify when something has happened\n */\nexport enum TouchSpinEmittedEvent {\n  /** Fired when minimum boundary is reached */\n  ON_MIN = 'touchspin.on.min',\n\n  /** Fired when maximum boundary is reached */\n  ON_MAX = 'touchspin.on.max',\n\n  /** Fired when any spinning starts */\n  ON_START_SPIN = 'touchspin.on.startspin',\n\n  /** Fired when any spinning stops */\n  ON_STOP_SPIN = 'touchspin.on.stopspin',\n\n  /** Fired when upward spinning starts */\n  ON_START_UP_SPIN = 'touchspin.on.startupspin',\n\n  /** Fired when downward spinning starts */\n  ON_START_DOWN_SPIN = 'touchspin.on.startdownspin',\n\n  /** Fired when upward spinning stops */\n  ON_STOP_UP_SPIN = 'touchspin.on.stopupspin',\n\n  /** Fired when downward spinning stops */\n  ON_STOP_DOWN_SPIN = 'touchspin.on.stopdownspin',\n\n  /** Fired when step size increases during spinning */\n  ON_SPEED_CHANGE = 'touchspin.on.speedchange',\n}\n\n/**\n * Type for update settings event data\n */\nexport interface TouchSpinUpdateSettingsData {\n  [key: string]: unknown;\n}\n","/**\n * Framework-agnostic core for TouchSpin (TypeScript)\n */\n\nexport type ForceStepDivisibility = 'none' | 'floor' | 'round' | 'ceil';\n\nexport type TouchSpinCalcCallback = (value: string) => string;\n\nexport type { TouchSpinUpdateSettingsData } from './events.js';\n// Export event types\nexport { TouchSpinCallableEvent, TouchSpinEmittedEvent } from './events.js';\n\nimport type { Renderer, RendererConstructor } from './renderer.js';\n\n// Export renderer types\nexport type { Renderer, RendererConstructor } from './renderer.js';\n\ntype WithCoreElement = HTMLInputElement & { [INSTANCE_KEY]?: TouchSpinCore };\n\nexport interface TouchSpinCoreOptions {\n  min?: number | null;\n  max?: number | null;\n  firstclickvalueifempty?: number | null;\n  step?: number;\n  decimals?: number;\n  forcestepdivisibility?: ForceStepDivisibility;\n  stepinterval?: number;\n  stepintervaldelay?: number;\n  booster?: boolean;\n  boostat?: number;\n  maxboostedstep?: number | false;\n  callback_before_calculation?: TouchSpinCalcCallback;\n  callback_after_calculation?: TouchSpinCalcCallback;\n  // Renderer constructor (e.g., Bootstrap5Renderer) or null for no UI\n  renderer?: RendererConstructor | null | undefined;\n  initval?: string | number;\n  replacementval?: string | number;\n  mousewheel?: boolean;\n  verticalbuttons?: boolean;\n  verticalup?: string;\n  verticaldown?: string;\n  verticalupclass?: string | null;\n  verticaldownclass?: string | null;\n  focusablebuttons?: boolean;\n  prefix?: string;\n  postfix?: string;\n  prefix_extraclass?: string;\n  postfix_extraclass?: string;\n  buttonup_class?: string | null;\n  buttondown_class?: string | null;\n  buttonup_txt?: string;\n  buttondown_txt?: string;\n}\n\nconst DEFAULTS: Required<Omit<TouchSpinCoreOptions, 'renderer'>> & { renderer: null } = {\n  min: 0,\n  max: 100,\n  initval: '',\n  replacementval: '',\n  firstclickvalueifempty: null,\n  step: 1,\n  decimals: 0,\n  forcestepdivisibility: 'round',\n  stepinterval: 100,\n  stepintervaldelay: 500,\n  verticalbuttons: false,\n  verticalup: '+',\n  verticaldown: '−',\n  verticalupclass: null,\n  verticaldownclass: null,\n  focusablebuttons: false,\n  prefix: '',\n  postfix: '',\n  prefix_extraclass: '',\n  postfix_extraclass: '',\n  booster: true,\n  boostat: 10,\n  maxboostedstep: false,\n  mousewheel: true,\n  buttonup_class: null,\n  buttondown_class: null,\n  buttonup_txt: '+',\n  buttondown_txt: '&minus;',\n  callback_before_calculation: (v) => v,\n  callback_after_calculation: (v) => v,\n  renderer: null,\n};\n\nconst INSTANCE_KEY = '_touchSpinCore' as const;\n\ntype CoreEventName =\n  | 'min'\n  | 'max'\n  | 'startspin'\n  | 'startupspin'\n  | 'startdownspin'\n  | 'stopspin'\n  | 'stopupspin'\n  | 'stopdownspin'\n  | 'speedchange';\n\nexport class TouchSpinCore {\n  input: HTMLInputElement;\n  settings: TouchSpinCoreOptions;\n  spinning: boolean;\n  spincount: number;\n  direction: false | 'up' | 'down';\n  private _currentStepSize: number;\n  private _teardownCallbacks: Array<() => void> = [];\n  private _settingObservers: Map<string, Set<(value: unknown, prev?: unknown) => void>> = new Map();\n  private _spinDelayTimeout: ReturnType<typeof setTimeout> | null = null;\n  private _spinIntervalTimer: ReturnType<typeof setInterval> | null = null;\n  private _upButton: (HTMLElement & { disabled?: boolean }) | null = null;\n  private _originalAttributes: {\n    type: string | null;\n    attributes: Map<string, string | null>;\n  } | null = null;\n  private _downButton: (HTMLElement & { disabled?: boolean }) | null = null;\n  private _mutationObserver: MutationObserver | null = null;\n  private _wrapper: HTMLElement | null = null;\n  renderer?: Renderer;\n  /**\n   * Sanitize a partial settings object BEFORE applying it.\n   * Returns a new object with only provided keys normalized.\n   * @param {Partial<TouchSpinCoreOptions>} partial\n   * @param {TouchSpinCoreOptions} current\n   * @returns {Partial<TouchSpinCoreOptions>}\n   */\n  static sanitizePartialSettings(\n    partial: Partial<TouchSpinCoreOptions>,\n    _current: TouchSpinCoreOptions\n  ): Partial<TouchSpinCoreOptions> {\n    const out = { ...partial };\n\n    if (Object.hasOwn(partial, 'step')) {\n      const n = Number(partial.step);\n      out.step = Number.isFinite(n) && n > 0 ? n : 1;\n    }\n\n    if (Object.hasOwn(partial, 'decimals')) {\n      const n = Number(partial.decimals);\n      out.decimals = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;\n    }\n\n    const hasMin = Object.hasOwn(partial, 'min');\n    const hasMax = Object.hasOwn(partial, 'max');\n    if (hasMin) {\n      if (\n        partial.min === null ||\n        partial.min === undefined ||\n        (typeof partial.min === 'string' && partial.min === '')\n      ) {\n        out.min = null;\n      } else {\n        const n = Number(partial.min);\n        out.min = Number.isFinite(n) ? n : null;\n      }\n    }\n    if (hasMax) {\n      if (\n        partial.max === null ||\n        partial.max === undefined ||\n        (typeof partial.max === 'string' && partial.max === '')\n      ) {\n        out.max = null;\n      } else {\n        const n = Number(partial.max);\n        out.max = Number.isFinite(n) ? n : null;\n      }\n    }\n    if (\n      hasMin &&\n      hasMax &&\n      out.min != null &&\n      out.max != null &&\n      typeof out.min === 'number' &&\n      typeof out.max === 'number' &&\n      out.min > out.max\n    ) {\n      const tmp = out.min;\n      out.min = out.max;\n      out.max = tmp;\n    }\n\n    if (Object.hasOwn(partial, 'stepinterval')) {\n      const n = Number(partial.stepinterval);\n      out.stepinterval = Number.isFinite(n) && n >= 0 ? n : DEFAULTS.stepinterval;\n    }\n    if (Object.hasOwn(partial, 'stepintervaldelay')) {\n      const n = Number(partial.stepintervaldelay);\n      out.stepintervaldelay = Number.isFinite(n) && n >= 0 ? n : DEFAULTS.stepintervaldelay;\n    }\n\n    return out;\n  }\n  /**\n   * @param inputEl The input element\n   * @param opts Partial settings\n   */\n  constructor(inputEl: HTMLInputElement, opts: Partial<TouchSpinCoreOptions> = {}) {\n    if (!inputEl || inputEl.nodeName !== 'INPUT') {\n      throw new Error('TouchSpinCore requires an <input> element');\n    }\n\n    /** @type {HTMLInputElement} */\n    this.input = inputEl;\n\n    // Parse data-bts-* attributes\n    const dataAttrs = this._parseDataAttributes(inputEl);\n\n    // Allow global default options (e.g., to set a global default renderer or defaults)\n    /** @type {Partial<TouchSpinCoreOptions>} */\n    const globalDefaults =\n      typeof globalThis !== 'undefined' &&\n      (globalThis as unknown as { TouchSpinDefaultOptions?: Partial<TouchSpinCoreOptions> })\n        .TouchSpinDefaultOptions\n        ? TouchSpinCore.sanitizePartialSettings(\n            (globalThis as unknown as { TouchSpinDefaultOptions?: Partial<TouchSpinCoreOptions> })\n              .TouchSpinDefaultOptions!,\n            DEFAULTS\n          )\n        : {};\n\n    /** @type {TouchSpinCoreOptions} */\n    this.settings = Object.assign({}, DEFAULTS, globalDefaults, dataAttrs, opts);\n    // Sanitize settings to ensure safe, predictable behavior\n    this._sanitizeSettings();\n\n    // Initialize current step size after settings are finalized\n    this._currentStepSize = this.settings.step || 1;\n\n    // Check for renderer: explicit option > global default > none\n    if (!this.settings.renderer) {\n      // Check for global default renderer\n      const g = globalThis as unknown as { TouchSpinDefaultRenderer?: RendererConstructor };\n      if (g?.TouchSpinDefaultRenderer) {\n        this.settings.renderer = g.TouchSpinDefaultRenderer;\n      } else {\n        // Allow no renderer for keyboard/wheel-only functionality\n        console.warn(\n          'TouchSpin: No renderer specified (renderer: null). Only keyboard/wheel events will work. Consider using Bootstrap3/4/5Renderer or TailwindRenderer for UI.'\n        );\n      }\n    }\n\n    /** @type {boolean} */\n    this.spinning = false;\n    /** @type {number} */\n    this.spincount = 0;\n    /** @type {false|'up'|'down'} */\n    this.direction = false;\n    /** @type {Array<Function>} */\n    this._teardownCallbacks = [];\n    /** @type {Map<string, Set<Function>>} */\n    this._settingObservers = new Map(); // For observer pattern\n\n    /** @type {ReturnType<typeof setTimeout>|null} */\n    this._spinDelayTimeout = null;\n    /** @type {ReturnType<typeof setInterval>|null} */\n    this._spinIntervalTimer = null;\n\n    /** @type {HTMLElement|null} */\n    this._upButton = null;\n    /** @type {HTMLElement|null} */\n    this._downButton = null;\n    /** @type {HTMLElement|null} */\n    this._wrapper = null;\n\n    // DOM event handlers (bound methods)\n    this._handleUpMouseDown = this._handleUpMouseDown.bind(this);\n    this._handleDownMouseDown = this._handleDownMouseDown.bind(this);\n    this._handleMouseUp = this._handleMouseUp.bind(this);\n    this._handleUpKeyDown = this._handleUpKeyDown.bind(this);\n    this._handleUpKeyUp = this._handleUpKeyUp.bind(this);\n    this._handleDownKeyDown = this._handleDownKeyDown.bind(this);\n    this._handleDownKeyUp = this._handleDownKeyUp.bind(this);\n    this._handleWindowChangeCapture = this._handleWindowChangeCapture.bind(this);\n    this._handleKeyDown = this._handleKeyDown.bind(this);\n    this._handleKeyUp = this._handleKeyUp.bind(this);\n    this._handleWheel = this._handleWheel.bind(this);\n\n    // Core always manages the input element\n    this._initializeInput();\n\n    // Initialize renderer with reference to core\n    if (this.settings.renderer) {\n      const Ctor = this.settings.renderer as unknown as new (\n        inputEl: HTMLInputElement,\n        settings: Readonly<Record<string, unknown>>,\n        core: unknown\n      ) => Renderer;\n      this.renderer = new Ctor(\n        inputEl,\n        this.settings as unknown as Readonly<Record<string, unknown>>,\n        this\n      );\n      this.renderer.init();\n    }\n\n    // Set up mutation observer to watch for disabled/readonly changes\n    this._setupMutationObserver();\n\n    // Finalize wrapper attributes after complete initialization\n    //\n    // The data-touchspin-injected attribute serves as a marker that the TouchSpin\n    // component is fully constructed - DOM is built, event handlers are attached,\n    // and mutation observer is active. Tests use this attribute to detect when\n    // components are ready for interaction.\n    //\n    // By setting these attributes as the final initialization step, we prevent race\n    // conditions where tests might try to interact with components before their DOM\n    // structure, event handlers, or internal monitoring are ready. This is especially\n    // important under high CPU load where DOM operations may take longer.\n    //\n    // Complete initialization sequence:\n    // 1. renderer.init() - Constructs DOM and attaches event handlers\n    // 2. _setupMutationObserver() - Starts monitoring input attribute changes\n    // 3. renderer.finalizeWrapperAttributes() - Marks component as ready:\n    //    - Adds data-testid for test element selection\n    //    - Adds data-touchspin-injected to signal component is fully ready\n    if (this.renderer) {\n      this.renderer.finalizeWrapperAttributes();\n    }\n\n    this.input.setAttribute('data-touchspin-injected', 'input');\n  }\n\n  /**\n   * Initialize input element (core always handles this)\n   * @private\n   */\n  _initializeInput(): void {\n    // Capture original attributes before TouchSpin modifies anything\n    this._captureOriginalAttributes();\n\n    // Set initial value if specified and input is empty\n    const initVal = this.settings.initval ?? '';\n    if (initVal !== '' && this.input.value === '') {\n      this.input.value = String(initVal);\n    }\n\n    // Core always handles these for the input\n    this._updateAriaAttributes();\n    this._syncNativeAttributes();\n    this._checkValue(false);\n  }\n\n  /**\n   * Normalize and validate settings: coerce invalid values to safe defaults.\n   * - step: > 0 number, otherwise 1\n   * - decimals: integer >= 0, otherwise 0\n   * - min/max: finite numbers or null\n   * - stepinterval/stepintervaldelay: integers >= 0 (fallback to defaults if invalid)\n   * @private\n   */\n  _sanitizeSettings(): void {\n    // step\n    const stepNum = Number(this.settings.step);\n    if (!Number.isFinite(stepNum) || stepNum <= 0) {\n      this.settings.step = 1;\n    } else {\n      this.settings.step = stepNum;\n    }\n\n    // decimals\n    const decNum = Number(this.settings.decimals);\n    if (!Number.isFinite(decNum) || decNum < 0) {\n      this.settings.decimals = 0;\n    } else {\n      this.settings.decimals = Math.floor(decNum);\n    }\n\n    // min/max\n    // Preserve explicit nulls; coerce other values to numbers or null\n    if (\n      this.settings.min === null ||\n      this.settings.min === undefined ||\n      (typeof this.settings.min === 'string' && this.settings.min === '')\n    ) {\n      this.settings.min = null;\n    } else {\n      const minNum = Number(this.settings.min);\n      this.settings.min = Number.isFinite(minNum) ? minNum : null;\n    }\n    if (\n      this.settings.max === null ||\n      this.settings.max === undefined ||\n      (typeof this.settings.max === 'string' && this.settings.max === '')\n    ) {\n      this.settings.max = null;\n    } else {\n      const maxNum = Number(this.settings.max);\n      this.settings.max = Number.isFinite(maxNum) ? maxNum : null;\n    }\n\n    // Ensure min <= max when both present\n    if (\n      this.settings.min !== null &&\n      this.settings.max !== null &&\n      this.settings.min > this.settings.max\n    ) {\n      // Swap to maintain logical bounds\n      const tmp = this.settings.min;\n      this.settings.min = this.settings.max;\n      this.settings.max = tmp;\n    }\n\n    // stepinterval\n    const si = Number(this.settings.stepinterval);\n    if (!Number.isFinite(si) || si < 0) this.settings.stepinterval = DEFAULTS.stepinterval;\n\n    // stepintervaldelay\n    const sid = Number(this.settings.stepintervaldelay);\n    if (!Number.isFinite(sid) || sid < 0)\n      this.settings.stepintervaldelay = DEFAULTS.stepintervaldelay;\n\n    // Validate callbacks and handle input type conversion if needed\n    this._validateCallbacks();\n\n    // Check for callback pairing and warn if needed\n    this._checkCallbackPairing();\n  }\n\n  /**\n   * Validate callbacks and automatically convert number inputs to text inputs\n   * when formatting callbacks that add non-numeric characters are detected.\n   * @private\n   */\n  _validateCallbacks(): void {\n    // Only validate for number inputs\n    const currentType = this.input.getAttribute('type');\n    if (currentType !== 'number') return;\n\n    // Only check if callbacks are set (not default)\n    const defaultCallback = (v: string) => v;\n    if (\n      !this.settings.callback_after_calculation ||\n      this.settings.callback_after_calculation.toString() === defaultCallback.toString()\n    )\n      return;\n\n    // Test the callback with a sample value\n    const testValue = '123.45';\n    const afterResult = this.settings.callback_after_calculation(testValue);\n\n    // Check if result contains non-numeric characters\n    // Allow: optional minus, digits, optional decimal point and digits\n    if (!/^-?\\d*\\.?\\d*$/.test(afterResult)) {\n      console.warn(\n        'TouchSpin: Detected formatting callback that adds non-numeric characters. ' +\n          'Converting input from type=\"number\" to type=\"text\" to support formatting like \"' +\n          afterResult +\n          '\". ' +\n          'This ensures compatibility with custom formatting while maintaining full TouchSpin functionality. ' +\n          'The original type will be restored when TouchSpin is destroyed.'\n      );\n\n      // Capture original attributes before making any changes\n      this._captureOriginalAttributes();\n\n      // Convert input type to text to support formatting\n      this.input.setAttribute('type', 'text');\n\n      // Hint preferred virtual keyboard if not already specified\n      const step = this.settings.step ?? 1;\n      const decimalsSetting = this.settings.decimals ?? 0;\n      const hasDecimals = decimalsSetting > 0 || step % 1 !== 0;\n      const minSetting = this.settings.min ?? null;\n      const allowNegative =\n        minSetting === null || (typeof minSetting === 'number' && minSetting < 0);\n      if (!this.input.hasAttribute('inputmode')) {\n        const inputMode = hasDecimals || allowNegative ? 'decimal' : 'numeric';\n        this.input.setAttribute('inputmode', inputMode);\n      }\n\n      // Remove native number attributes only if TouchSpin added them\n      ['min', 'max', 'step'].forEach((attr) => {\n        const originalValue = this._originalAttributes?.attributes.get(attr) ?? null;\n        if (originalValue === null) {\n          this.input.removeAttribute(attr);\n        }\n      });\n    }\n  }\n\n  /**\n   * Capture the original attributes of the input before TouchSpin modifies them.\n   * This ensures complete transparency - the input can be restored to its exact original state.\n   * @private\n   */\n  _captureOriginalAttributes(): void {\n    if (this._originalAttributes !== null) return; // Already captured\n\n    const attributesToTrack = [\n      'role',\n      'aria-valuemin',\n      'aria-valuemax',\n      'aria-valuenow',\n      'aria-valuetext',\n      'min',\n      'max',\n      'step',\n      'inputmode',\n    ];\n\n    this._originalAttributes = {\n      type: this.input.getAttribute('type'),\n      attributes: new Map(),\n    };\n\n    // Store original values (null if attribute didn't exist)\n    attributesToTrack.forEach((attr) => {\n      this._originalAttributes?.attributes.set(attr, this.input.getAttribute(attr));\n    });\n  }\n\n  /**\n   * Restore the input to its original state by restoring all original attributes.\n   * This ensures complete transparency - the input returns to its exact original state.\n   * @private\n   */\n  _restoreOriginalAttributes(): void {\n    if (this._originalAttributes === null) return; // Nothing to restore\n\n    // Strip formatting from value using callback_before_calculation\n    // This uses the opposite callback to revert formatted values (e.g., \"51 USD\" -> \"51\")\n    const currentValue = this.input.value;\n    if (currentValue && this.settings.callback_before_calculation) {\n      const rawValue = this.settings.callback_before_calculation(currentValue);\n      this.input.value = rawValue;\n    }\n\n    // Restore original type\n    if (this._originalAttributes.type) {\n      this.input.setAttribute('type', this._originalAttributes.type);\n    }\n\n    // Restore all original attributes\n    this._originalAttributes.attributes.forEach((originalValue, attrName) => {\n      if (originalValue === null) {\n        // Attribute didn't exist originally, remove it\n        this.input.removeAttribute(attrName);\n      } else {\n        // Restore original value\n        this.input.setAttribute(attrName, originalValue);\n      }\n    });\n\n    this._originalAttributes = null;\n  }\n\n  /**\n   * Parse data-bts-* attributes from the input element.\n   * @param {HTMLInputElement} inputEl\n   * @returns {Partial<TouchSpinCoreOptions>}\n   * @private\n   */\n  _parseDataAttributes(inputEl: HTMLInputElement): Partial<TouchSpinCoreOptions> {\n    const attributeMap: Partial<Record<keyof TouchSpinCoreOptions, string>> = {\n      min: 'min',\n      max: 'max',\n      initval: 'init-val',\n      replacementval: 'replacement-val',\n      firstclickvalueifempty: 'first-click-value-if-empty',\n      step: 'step',\n      decimals: 'decimals',\n      stepinterval: 'step-interval',\n      verticalbuttons: 'vertical-buttons',\n      verticalup: 'vertical-up',\n      verticaldown: 'vertical-down',\n      verticalupclass: 'vertical-up-class',\n      verticaldownclass: 'vertical-down-class',\n      forcestepdivisibility: 'force-step-divisibility',\n      stepintervaldelay: 'step-interval-delay',\n      prefix: 'prefix',\n      postfix: 'postfix',\n      prefix_extraclass: 'prefix-extra-class',\n      postfix_extraclass: 'postfix-extra-class',\n      booster: 'booster',\n      boostat: 'boostat',\n      maxboostedstep: 'max-boosted-step',\n      mousewheel: 'mouse-wheel',\n      buttondown_class: 'button-down-class',\n      buttonup_class: 'button-up-class',\n      buttondown_txt: 'button-down-txt',\n      buttonup_txt: 'button-up-txt',\n    };\n\n    const parsed: Partial<TouchSpinCoreOptions> = {};\n\n    // Parse data-bts-* attributes\n    for (const [optionName, attrName] of Object.entries(attributeMap) as Array<\n      [keyof TouchSpinCoreOptions, string]\n    >) {\n      const fullAttrName = `data-bts-${attrName}`;\n      if (inputEl.hasAttribute(fullAttrName)) {\n        const rawValue = inputEl.getAttribute(fullAttrName);\n        // Assign strongly typed value\n\n        // @ts-expect-error - narrow via optionName switch\n        parsed[optionName] = this._coerceAttributeValue(optionName as string, rawValue ?? '');\n      }\n    }\n\n    // Also handle native attributes with precedence (warn if both specified)\n    for (const nativeAttr of ['min', 'max', 'step']) {\n      if (inputEl.hasAttribute(nativeAttr)) {\n        const rawValue = inputEl.getAttribute(nativeAttr);\n        if ((parsed as Record<string, unknown>)[nativeAttr] !== undefined) {\n          console.warn(\n            `Both \"data-bts-${nativeAttr}\" and \"${nativeAttr}\" attributes specified. Native attribute takes precedence.`,\n            inputEl\n          );\n        }\n\n        // @ts-expect-error\n        parsed[nativeAttr as keyof TouchSpinCoreOptions] = this._coerceAttributeValue(\n          nativeAttr,\n          rawValue ?? ''\n        );\n      }\n    }\n\n    return parsed;\n  }\n\n  /**\n   * Convert string attribute values to appropriate types.\n   * @param {string} optionName\n   * @param {string} rawValue\n   * @returns {any}\n   * @private\n   */\n  _coerceAttributeValue(optionName: string, rawValue: string) {\n    if (rawValue === null || rawValue === undefined) {\n      return rawValue;\n    }\n\n    // Boolean attributes\n    if (['booster', 'mousewheel', 'verticalbuttons', 'focusablebuttons'].includes(optionName)) {\n      return rawValue === 'true' || rawValue === '' || rawValue === optionName;\n    }\n\n    // Numeric attributes\n    if (\n      [\n        'min',\n        'max',\n        'step',\n        'decimals',\n        'stepinterval',\n        'stepintervaldelay',\n        'boostat',\n        'maxboostedstep',\n        'firstclickvalueifempty',\n      ].includes(optionName)\n    ) {\n      const num = parseFloat(rawValue);\n      return Number.isNaN(num) ? rawValue : num;\n    }\n\n    // String attributes - return as-is\n    return rawValue;\n  }\n\n  /** Increment once according to step */\n  upOnce(): void {\n    if (this.input.disabled || this.input.hasAttribute('readonly')) {\n      return;\n    }\n\n    const v = this.getValue();\n    const next = this._nextValue('up', v);\n\n    // Check if already at max boundary before incrementing\n    if (this.settings.max !== null && v === this.settings.max) {\n      this.emit('max'); // Emit event for feedback\n      if (this.spinning && this.direction === 'up') {\n        this.stopSpin();\n      }\n      return;\n    }\n\n    // Fire max event BEFORE setting display if we're reaching max\n    if (this.settings.max !== null && next === this.settings.max) {\n      this.emit('max');\n      if (this.spinning && this.direction === 'up') {\n        this.stopSpin();\n      }\n    }\n\n    this._setDisplay(next, true);\n  }\n\n  /** Decrement once according to step */\n  downOnce(): void {\n    if (this.input.disabled || this.input.hasAttribute('readonly')) {\n      return;\n    }\n\n    const v = this.getValue();\n    const next = this._nextValue('down', v);\n\n    // Check if already at min boundary before decrementing\n    if (this.settings.min !== null && v === this.settings.min) {\n      this.emit('min'); // Emit event for feedback\n      if (this.spinning && this.direction === 'down') {\n        this.stopSpin();\n      }\n      return;\n    }\n\n    // Fire min event BEFORE setting display if we're reaching min\n    if (this.settings.min !== null && next === this.settings.min) {\n      this.emit('min');\n      if (this.spinning && this.direction === 'down') {\n        this.stopSpin();\n      }\n    }\n\n    this._setDisplay(next, true);\n  }\n\n  /** Start increasing repeatedly; no immediate step here. */\n  startUpSpin(): void {\n    // Check boundary BEFORE emitting event - don't emit if already at max\n    const currentValue = this.getValue();\n    if (this.settings.max !== null && currentValue === this.settings.max) {\n      this.emit('max'); // Emit boundary event as feedback\n      return;\n    }\n\n    this._startSpin('up');\n  }\n\n  /** Start decreasing repeatedly; no immediate step here. */\n  startDownSpin(): void {\n    // Check boundary BEFORE emitting event - don't emit if already at min\n    const currentValue = this.getValue();\n    if (this.settings.min !== null && currentValue === this.settings.min) {\n      this.emit('min'); // Emit boundary event as feedback\n      return;\n    }\n\n    this._startSpin('down');\n  }\n\n  /** Stop spinning (placeholder) */\n  stopSpin(): void {\n    this._clearSpinTimers();\n    if (this.spinning) {\n      if (this.direction === 'up') {\n        this.emit('stopupspin');\n        this.emit('stopspin');\n      } else if (this.direction === 'down') {\n        this.emit('stopdownspin');\n        this.emit('stopspin');\n      }\n    }\n    this.spinning = false;\n    this.direction = false;\n    this.spincount = 0;\n    this._currentStepSize = this.settings.step || 1; // Reset step size when stopping\n  }\n\n  updateSettings(opts: Partial<TouchSpinCoreOptions>): void {\n    const oldSettings = { ...this.settings };\n    const newSettings = opts || {};\n\n    // Sanitize the incoming partial BEFORE merge\n    const sanitizedPartial = TouchSpinCore.sanitizePartialSettings(newSettings, oldSettings);\n\n    // Apply incoming changes (sanitized) first\n    Object.assign(this.settings, sanitizedPartial);\n    // Extra safety: sanitize full settings after merge\n    this._sanitizeSettings();\n\n    // If step/min/max changed and step != 1, align bounds to step like the jQuery plugin\n    const step = Number(this.settings.step || 1);\n    if (\n      (sanitizedPartial.step !== undefined ||\n        sanitizedPartial.min !== undefined ||\n        sanitizedPartial.max !== undefined) &&\n      step !== 1\n    ) {\n      if (this.settings.max !== null) {\n        this.settings.max = this._alignToStep(Number(this.settings.max), step, 'down');\n      }\n      if (this.settings.min !== null) {\n        this.settings.min = this._alignToStep(Number(this.settings.min), step, 'up');\n      }\n    }\n\n    // Notify observers of keys whose EFFECTIVE values changed after sanitization\n    (Object.keys(this.settings) as Array<keyof TouchSpinCoreOptions>).forEach((key) => {\n      if (oldSettings[key] !== this.settings[key]) {\n        const observers = this._settingObservers.get(String(key));\n        if (observers) {\n          observers.forEach((callback) => {\n            try {\n              callback(this.settings[key] as unknown, oldSettings[key] as unknown);\n            } catch (error) {\n              console.error('TouchSpin: Error in setting observer callback:', error);\n            }\n          });\n        }\n      }\n    });\n\n    // Core handles its own setting changes\n    this._updateAriaAttributes();\n    this._syncNativeAttributes();\n    this._checkValue(true); // Emit change events when updateSettings clamps values\n\n    // Reset current step size to reflect sanitized settings\n    this._currentStepSize = this.settings.step || 1;\n\n    // Check for callback pairing and warn if needed\n    this._checkCallbackPairing();\n  }\n\n  getValue(): number {\n    let raw = this.input.value;\n    const repl = this.settings.replacementval ?? '';\n    if (raw === '' && repl !== '') {\n      raw = String(repl);\n    }\n    if (raw === '') return NaN;\n    const before = this.settings.callback_before_calculation || ((v) => v);\n    const num = parseFloat(before(String(raw)));\n    return Number.isNaN(num) ? NaN : num;\n  }\n\n  setValue(v: number | string): void {\n    if (this.input.disabled || this.input.hasAttribute('readonly')) return;\n    const parsed = Number(v);\n    if (!Number.isFinite(parsed)) return;\n    const adjusted = this._applyConstraints(parsed);\n    const wasSanitized = parsed !== adjusted;\n    this._setDisplay(adjusted, true, wasSanitized, false);\n  }\n\n  /**\n   * Initialize DOM event handling by finding elements and attaching listeners.\n   * Must be called after the renderer has created the DOM structure.\n   */\n  initDOMEventHandling(): void {\n    // Find DOM elements and attach listeners\n    this._findDOMElements();\n    this._attachDOMEventListeners();\n  }\n\n  /**\n   * Register a teardown callback that will be called when the instance is destroyed.\n   * This allows wrapper libraries to register cleanup logic.\n   * @param {Function} callback - Function to call on destroy\n   * @returns {Function} - Unregister function\n   */\n  registerTeardown(callback: () => void): () => void {\n    if (typeof callback !== 'function') {\n      throw new Error('Teardown callback must be a function');\n    }\n    this._teardownCallbacks.push(callback);\n\n    // Return unregister function\n    return () => {\n      const index = this._teardownCallbacks.indexOf(callback);\n      if (index > -1) {\n        this._teardownCallbacks.splice(index, 1);\n      }\n    };\n  }\n\n  /** Cleanup and destroy the TouchSpin instance */\n  destroy(): void {\n    // Remove the data-touchspin-injected attribute first to signal plugin is no longer ready\n    this.input.removeAttribute('data-touchspin-injected');\n\n    this.stopSpin();\n\n    // Renderer cleans up its added elements\n    if (this.renderer?.teardown) {\n      this.renderer.teardown();\n    }\n\n    // Core cleans up input events only\n    this._detachDOMEventListeners();\n\n    // Call all registered teardown callbacks (for wrapper cleanup)\n    this._teardownCallbacks.forEach((callback) => {\n      try {\n        callback();\n      } catch (error) {\n        console.error('TouchSpin teardown callback error:', error);\n      }\n    });\n    this._teardownCallbacks.length = 0; // Clear the array\n\n    // Clear setting observers\n    this._settingObservers.clear();\n\n    // Clean up mutation observer\n    if (this._mutationObserver) {\n      this._mutationObserver.disconnect();\n      this._mutationObserver = null;\n    }\n\n    // Clear button references\n    this._upButton = null;\n    this._downButton = null;\n\n    // Restore all original attributes\n    this._restoreOriginalAttributes();\n\n    // Remove instance from element (type-safe guard)\n    type WithCore = HTMLInputElement & { [INSTANCE_KEY]?: TouchSpinCore };\n    const inst = (this.input as WithCore)[INSTANCE_KEY];\n    if (inst && inst === this) {\n      delete (this.input as WithCore)[INSTANCE_KEY];\n    }\n  }\n\n  /**\n   * Create a plain public API object with bound methods for wrappers.\n   * @returns {TouchSpinCorePublicAPI}\n   */\n  toPublicApi(): TouchSpinCorePublicAPI {\n    return {\n      upOnce: this.upOnce.bind(this),\n      downOnce: this.downOnce.bind(this),\n      startUpSpin: this.startUpSpin.bind(this),\n      startDownSpin: this.startDownSpin.bind(this),\n      stopSpin: this.stopSpin.bind(this),\n      updateSettings: this.updateSettings.bind(this),\n      getValue: this.getValue.bind(this),\n      setValue: this.setValue.bind(this),\n      destroy: this.destroy.bind(this),\n      initDOMEventHandling: this.initDOMEventHandling.bind(this),\n      registerTeardown: this.registerTeardown.bind(this),\n      attachUpEvents: this.attachUpEvents.bind(this),\n      attachDownEvents: this.attachDownEvents.bind(this),\n      observeSetting: this.observeSetting.bind(this),\n    };\n  }\n\n  // --- Renderer Event Attachment Methods ---\n  /**\n   * Attach up button events to an element\n   * Called by renderers after creating up button\n   * @param {HTMLElement|null} element - The element to attach events to\n   */\n  attachUpEvents(element: HTMLElement | null): void {\n    if (!element) {\n      console.warn('TouchSpin: attachUpEvents called with null element');\n      return;\n    }\n\n    this._upButton = element;\n    element.addEventListener('mousedown', this._handleUpMouseDown);\n    element.addEventListener('touchstart', this._handleUpMouseDown, { passive: false });\n\n    // Add keyboard event listeners if focusable buttons are enabled\n    if (this.settings.focusablebuttons) {\n      element.addEventListener('keydown', this._handleUpKeyDown);\n      element.addEventListener('keyup', this._handleUpKeyUp);\n    }\n\n    // Update disabled state immediately after attaching\n    this._updateButtonDisabledState();\n  }\n\n  /**\n   * Attach down button events to an element\n   * Called by renderers after creating down button\n   * @param {HTMLElement|null} element - The element to attach events to\n   */\n  attachDownEvents(element: HTMLElement | null): void {\n    if (!element) {\n      console.warn('TouchSpin: attachDownEvents called with null element');\n      return;\n    }\n\n    this._downButton = element;\n    element.addEventListener('mousedown', this._handleDownMouseDown);\n    element.addEventListener('touchstart', this._handleDownMouseDown, { passive: false });\n\n    // Add keyboard event listeners if focusable buttons are enabled\n    if (this.settings.focusablebuttons) {\n      element.addEventListener('keydown', this._handleDownKeyDown);\n      element.addEventListener('keyup', this._handleDownKeyUp);\n    }\n\n    // Update disabled state immediately after attaching\n    this._updateButtonDisabledState();\n  }\n\n  // --- Settings Observer Pattern ---\n  /**\n   * Allow renderers to observe setting changes\n   * @param {string} settingName - Name of setting to observe\n   * @param {Function} callback - Function to call when setting changes (newValue, oldValue)\n   * @returns {Function} Unsubscribe function\n   */\n  observeSetting<K extends keyof TouchSpinCoreOptions>(\n    settingName: K,\n    callback: (\n      newValue: NonNullable<TouchSpinCoreOptions[K]>,\n      oldValue?: TouchSpinCoreOptions[K]\n    ) => void\n  ): () => void {\n    const key = String(settingName);\n    if (!this._settingObservers.has(key)) {\n      this._settingObservers.set(key, new Set());\n    }\n\n    const observers = this._settingObservers.get(key)!;\n    observers.add(callback as (value: unknown, prev?: unknown) => void);\n\n    // Return unsubscribe function\n    return () => observers.delete(callback as (value: unknown, prev?: unknown) => void);\n  }\n\n  // --- Minimal internal emitter API ---\n\n  /**\n   * Emit a core event as DOM CustomEvent (matching original jQuery plugin behavior)\n   * @param event - Event name\n   * @param detail - Event detail data (can be modified for speedchange events)\n   * @param cancelable - Whether the event can be canceled (default: false)\n   * @returns Whether the event was prevented (only meaningful for cancelable events)\n   *\n   * Cancelable events include:\n   * - 'startupspin': emitted before starting upward spinning, can be prevented\n   * - 'startdownspin': emitted before starting downward spinning, can be prevented\n   * - 'speedchange': emitted before step size increases, can be prevented or modified\n   */\n  emit(event: CoreEventName, detail?: unknown, cancelable = false): boolean {\n    const domEventName = `touchspin.on.${event}`;\n    const customEvent = new CustomEvent(domEventName, {\n      detail,\n      bubbles: true,\n      cancelable,\n    });\n    const wasPrevented = !this.input.dispatchEvent(customEvent);\n    return wasPrevented;\n  }\n\n  /**\n   * Internal: start timed spin in a direction with initial step, delay, then interval.\n   * @param {'up'|'down'} dir\n   */\n  _startSpin(dir: 'up' | 'down'): void {\n    if (this.input.disabled || this.input.hasAttribute('readonly')) return;\n\n    // If already spinning in the same direction, do nothing (idempotent)\n    if (this.spinning && this.direction === dir) {\n      return;\n    }\n    // If switching direction while spinning, stop first\n    if (this.spinning && this.direction !== dir) {\n      this.stopSpin();\n    }\n\n    // Emit start events BEFORE the immediate step to get correct event order\n    // (start events → change event from step → stop events)\n    const direction_changed = !this.spinning || this.direction !== dir;\n    if (direction_changed) {\n      this.spinning = true;\n      this.direction = dir;\n      this.spincount = 0;\n      this._currentStepSize = this.settings.step || 1; // Reset step size when restarting spin\n      // Match jQuery plugin event order: startspin then direction-specific (cancelable)\n      this.emit('startspin');\n      if (dir === 'up') {\n        if (this.emit('startupspin', undefined, true)) {\n          // Prevented, abort spinning\n          this.spinning = false;\n          this.direction = false;\n          return;\n        }\n      } else {\n        if (this.emit('startdownspin', undefined, true)) {\n          // Prevented, abort spinning\n          this.spinning = false;\n          this.direction = false;\n          return;\n        }\n      }\n    }\n\n    // Perform an immediate single step after emitting start events (parity with jQuery plugin UX)\n    if (dir === 'up') this.upOnce();\n    else this.downOnce();\n\n    // If we reached a boundary after the initial step, don't start continuous spin\n    const v = this.getValue();\n    if (dir === 'up' && this.settings.max !== null && v === this.settings.max) {\n      return;\n    }\n    if (dir === 'down' && this.settings.min !== null && v === this.settings.min) {\n      return;\n    }\n\n    // Clear previous timers\n    this._clearSpinTimers();\n    // Schedule repeat after delay, then at interval\n    const delay = this.settings.stepintervaldelay || 500;\n    const interval = this.settings.stepinterval || 100;\n    this._spinDelayTimeout = setTimeout(() => {\n      this._spinDelayTimeout = null;\n      this._spinIntervalTimer = setInterval(() => {\n        if (!this.spinning || this.direction !== dir) return; // safety\n        this._spinStep(dir);\n      }, interval);\n    }, delay);\n  }\n\n  _clearSpinTimers(): void {\n    try {\n      if (this._spinDelayTimeout) {\n        clearTimeout(this._spinDelayTimeout);\n      }\n    } catch {\n      // Ignore timer cleanup errors\n    }\n    try {\n      if (this._spinIntervalTimer) {\n        clearInterval(this._spinIntervalTimer);\n      }\n    } catch {\n      // Ignore timer cleanup errors\n    }\n    this._spinDelayTimeout = null;\n    this._spinIntervalTimer = null;\n  }\n\n  /**\n   * Compute the next numeric value for a direction, respecting step, booster and bounds.\n   * @param {'up'|'down'} dir\n   * @param {number} current\n   */\n  _nextValue(dir: 'up' | 'down', current: number): number {\n    let v = current;\n    if (Number.isNaN(v)) {\n      v = this._valueIfIsNaN();\n    } else {\n      const base = this.settings.step || 1;\n      const mbs = this.settings.maxboostedstep;\n      let stepCandidate = base;\n      // Apply booster only if enabled\n      if (this.settings.booster) {\n        const boostat = Math.max(1, parseInt(String(this.settings.boostat || 10), 10));\n        stepCandidate = 2 ** Math.floor(this.spincount / boostat) * base;\n      }\n      let step = stepCandidate;\n      if (mbs && Number.isFinite(mbs) && stepCandidate > Number(mbs)) {\n        step = Number(mbs);\n        // Align current value to the boosted step grid when clamped (parity with jQuery plugin)\n        v = Math.round(v / step) * step;\n      }\n      step = Math.max(base, step);\n      const currentSize = this._currentStepSize ?? base;\n      if (step < currentSize) {\n        step = currentSize;\n      }\n\n      // Check for speed change and emit event if step size increased\n      if (step > this._currentStepSize) {\n        const currentLevel = Math.floor(Math.log2(this._currentStepSize / base));\n        const newLevel = Math.floor(Math.log2(step / base));\n\n        // Create modifiable event data\n        const eventData = {\n          currentLevel,\n          newLevel,\n          currentStep: this._currentStepSize,\n          newStep: step,\n          direction: dir,\n        };\n\n        // Emit cancelable speed change event\n        if (this.emit('speedchange', eventData, true)) {\n          // Event was prevented, keep current step size\n          step = this._currentStepSize;\n        } else {\n          // Event was not prevented, use potentially modified step size\n          // Validate the modified step size to ensure it's safe\n          const modifiedStep = Number(eventData.newStep);\n          if (Number.isFinite(modifiedStep) && modifiedStep >= this.settings.step!) {\n            step = Math.min(modifiedStep, this.settings.maxboostedstep || Number.MAX_SAFE_INTEGER);\n          } else {\n            // Invalid modification, revert to the already-clamped step\n            step = this._currentStepSize;\n          }\n        }\n      }\n\n      // Update current step size\n      this._currentStepSize = step;\n\n      v = dir === 'up' ? v + step : v - step;\n    }\n    return this._applyConstraints(v);\n  }\n\n  /** Returns a reasonable value to use when current is NaN. */\n  _valueIfIsNaN(): number {\n    if (typeof this.settings.firstclickvalueifempty === 'number') {\n      return this.settings.firstclickvalueifempty;\n    }\n    const min = typeof this.settings.min === 'number' ? this.settings.min : 0;\n    const max = typeof this.settings.max === 'number' ? this.settings.max : min;\n    return (min + max) / 2;\n  }\n\n  /** Apply step divisibility and clamp to min/max. */\n  _applyConstraints(v: number): number {\n    const aligned = this._forcestepdivisibility(v);\n    const min = this.settings.min ?? null;\n    const max = this.settings.max ?? null;\n    let clamped = aligned;\n    if (typeof min === 'number' && clamped < min) clamped = min;\n    if (typeof max === 'number' && clamped > max) clamped = max;\n    return clamped;\n  }\n\n  /** Determine the effective step with booster if enabled. */\n  _getBoostedStep(): number {\n    const base = this.settings.step || 1;\n    if (!this.settings.booster) return base;\n    const boostat = Math.max(1, parseInt(String(this.settings.boostat || 10), 10));\n    let boosted = 2 ** Math.floor(this.spincount / boostat) * base;\n    const mbs = this.settings.maxboostedstep;\n    if (mbs && Number.isFinite(mbs)) {\n      const cap = Number(mbs);\n      if (boosted > cap) boosted = cap;\n    }\n    return Math.max(base, boosted);\n  }\n\n  /** Aligns value to step per forcestepdivisibility. */\n  _forcestepdivisibility(val: number): number {\n    const mode = this.settings.forcestepdivisibility || 'round';\n    const step = this.settings.step || 1;\n    const dec = this.settings.decimals || 0;\n    let out;\n    switch (mode) {\n      case 'floor':\n        out = Math.floor(val / step) * step;\n        break;\n      case 'ceil':\n        out = Math.ceil(val / step) * step;\n        break;\n      case 'none':\n        out = val;\n        break;\n      default:\n        out = Math.round(val / step) * step;\n        break;\n    }\n    const result = Number(out.toFixed(dec));\n    // Normalize to configured decimals without string pipeline; formatting applies later\n    return result;\n  }\n\n  /** Aligns a value to nearest step boundary using integer arithmetic. */\n  _alignToStep(val: number, step: number, dir: 'up' | 'down'): number {\n    if (step === 0) return val;\n    let k = 1;\n    const s = step;\n    while ((s * k) % 1 !== 0 && k < 1e6) k *= 10;\n    const V = Math.round(val * k);\n    const S = Math.round(step * k);\n    const r = V % S;\n    if (r === 0) return val;\n    return (dir === 'down' ? V - r : V + (S - r)) / k;\n  }\n\n  /** Format and write to input, optionally emit change if different. */\n  _setDisplay(\n    num: number,\n    mayTriggerChange: boolean,\n    forceTrigger: boolean = false,\n    onlyTriggerIfSanitized: boolean = false\n  ): string {\n    const prev = String(this.input.value ?? '');\n    const next = this._formatDisplay(num);\n    this.input.value = next;\n    this._updateAriaAttributes();\n\n    // Emit change based on context:\n    // - For programmatic setValue: only if sanitized (forceTrigger)\n    // - For user interactions: if sanitized OR display changed\n    if (\n      mayTriggerChange &&\n      (onlyTriggerIfSanitized ? forceTrigger : forceTrigger || prev !== next)\n    ) {\n      // mirror plugin behavior: trigger a native change event\n      this.input.dispatchEvent(new Event('change', { bubbles: true }));\n    }\n    return next;\n  }\n\n  _formatDisplay(num: number): string {\n    const dec = this.settings.decimals || 0;\n    const after = this.settings.callback_after_calculation || ((v) => v);\n    const s = Number(num).toFixed(dec);\n    return after(s);\n  }\n\n  /**\n   * Perform one spin step in a direction while tracking spincount for booster.\n   * @param {'up'|'down'} dir\n   */\n  _spinStep(dir: 'up' | 'down'): void {\n    this.spincount++;\n    if (dir === 'up') this.upOnce();\n    else this.downOnce();\n  }\n\n  /** Sanitize current input value and update display; optionally emits change. */\n  _checkValue(mayTriggerChange: boolean): void {\n    const v = this.getValue();\n    if (!Number.isFinite(v)) return;\n    const adjusted = this._applyConstraints(v);\n    const wasSanitized = v !== adjusted;\n    this._setDisplay(adjusted, !!mayTriggerChange, wasSanitized);\n  }\n\n  _updateAriaAttributes(): void {\n    const el = this.input;\n    if (el.getAttribute('role') !== 'spinbutton') {\n      el.setAttribute('role', 'spinbutton');\n    }\n    const min = this.settings.min ?? null;\n    const max = this.settings.max ?? null;\n    if (typeof min === 'number') el.setAttribute('aria-valuemin', String(min));\n    else el.removeAttribute('aria-valuemin');\n    if (typeof max === 'number') el.setAttribute('aria-valuemax', String(max));\n    else el.removeAttribute('aria-valuemax');\n    const raw = el.value;\n    const before = this.settings.callback_before_calculation || ((v) => v);\n    const num = parseFloat(before(String(raw)));\n    if (Number.isFinite(num)) el.setAttribute('aria-valuenow', String(num));\n    else el.removeAttribute('aria-valuenow');\n    el.setAttribute('aria-valuetext', String(raw));\n  }\n\n  /**\n   * Synchronize TouchSpin settings to native input attributes.\n   * Only applies to type=\"number\" inputs to maintain browser consistency.\n   * @private\n   */\n  _syncNativeAttributes(): void {\n    // Only set native attributes on number inputs\n    if (this.input.getAttribute('type') === 'number') {\n      // Sync min attribute\n      const min = this.settings.min ?? null;\n      if (typeof min === 'number' && Number.isFinite(min)) {\n        this.input.setAttribute('min', String(min));\n      } else {\n        this.input.removeAttribute('min');\n      }\n\n      // Sync max attribute\n      const max = this.settings.max ?? null;\n      if (typeof max === 'number' && Number.isFinite(max)) {\n        this.input.setAttribute('max', String(max));\n      } else {\n        this.input.removeAttribute('max');\n      }\n\n      // Sync step attribute\n      const step = this.settings.step;\n      if (typeof step === 'number' && Number.isFinite(step) && step > 0) {\n        this.input.setAttribute('step', String(step));\n      } else {\n        this.input.removeAttribute('step');\n      }\n    }\n  }\n\n  /**\n   * Update TouchSpin settings from native attribute changes.\n   * Called by mutation observer when min/max/step attributes change.\n   * @private\n   */\n  _syncSettingsFromNativeAttributes(): void {\n    const nativeMin = this.input.getAttribute('min');\n    const nativeMax = this.input.getAttribute('max');\n    const nativeStep = this.input.getAttribute('step');\n    let needsUpdate = false;\n    const newSettings: Partial<TouchSpinCoreOptions> = {};\n\n    // Check min attribute\n    if (nativeMin !== null) {\n      const parsedMin = nativeMin === '' ? null : parseFloat(nativeMin);\n      const minNum = parsedMin !== null && Number.isFinite(parsedMin) ? parsedMin : null;\n      if (minNum !== this.settings.min) {\n        newSettings.min = minNum;\n        needsUpdate = true;\n      }\n    } else if (this.settings.min !== null) {\n      // Attribute was removed\n      newSettings.min = null;\n      needsUpdate = true;\n    }\n\n    // Check max attribute\n    if (nativeMax !== null) {\n      const parsedMax = nativeMax === '' ? null : parseFloat(nativeMax);\n      const maxNum = parsedMax !== null && Number.isFinite(parsedMax) ? parsedMax : null;\n      if (maxNum !== this.settings.max) {\n        newSettings.max = maxNum;\n        needsUpdate = true;\n      }\n    } else if (this.settings.max !== null) {\n      // Attribute was removed\n      newSettings.max = null;\n      needsUpdate = true;\n    }\n\n    // Check step attribute\n    if (nativeStep !== null) {\n      const parsedStep = nativeStep === '' ? undefined : parseFloat(nativeStep);\n      const stepNum: number | undefined =\n        parsedStep !== undefined && Number.isFinite(parsedStep) && parsedStep > 0\n          ? parsedStep\n          : undefined;\n      if (stepNum !== this.settings.step) {\n        newSettings.step = stepNum ?? 1;\n        needsUpdate = true;\n      }\n    } else if (this.settings.step !== 1) {\n      // Attribute was removed, reset to default\n      newSettings.step = 1;\n      needsUpdate = true;\n    }\n\n    // Apply updates if needed\n    if (needsUpdate) {\n      this.updateSettings(newSettings);\n    }\n  }\n\n  // --- DOM Event Handling Methods ---\n\n  /**\n   * Find and store references to DOM elements using data-touchspin-injected attributes.\n   * @private\n   */\n  _findDOMElements(): void {\n    // Core doesn't need to find buttons - renderers handle button events directly\n    // We only need to find the wrapper for potential future use\n    let wrapper = this.input.parentElement;\n    while (wrapper && !wrapper.hasAttribute('data-touchspin-injected')) {\n      wrapper = wrapper.parentElement;\n    }\n    this._wrapper = wrapper;\n  }\n\n  /**\n   * Attach DOM event listeners to elements.\n   * @private\n   */\n  _attachDOMEventListeners(): void {\n    // Core should NOT attach button events - renderers handle that via attachUpEvents/attachDownEvents\n\n    // Global mouseup/touchend to stop spinning\n    document.addEventListener('mouseup', this._handleMouseUp);\n    document.addEventListener('mouseleave', this._handleMouseUp);\n    document.addEventListener('touchend', this._handleMouseUp);\n    window.addEventListener('change', this._handleWindowChangeCapture, true);\n\n    // Input events (always attach these - they work without renderer UI)\n    this.input.addEventListener('keydown', this._handleKeyDown);\n    this.input.addEventListener('keyup', this._handleKeyUp);\n    this.input.addEventListener('wheel', this._handleWheel);\n  }\n\n  /**\n   * Remove DOM event listeners.\n   * @private\n   */\n  _detachDOMEventListeners(): void {\n    // Core does not manage button events - renderers handle their own cleanup\n\n    // Global events\n    document.removeEventListener('mouseup', this._handleMouseUp);\n    document.removeEventListener('mouseleave', this._handleMouseUp);\n    document.removeEventListener('touchend', this._handleMouseUp);\n    window.removeEventListener('change', this._handleWindowChangeCapture, true);\n\n    // Input events\n    this.input.removeEventListener('keydown', this._handleKeyDown);\n    this.input.removeEventListener('keyup', this._handleKeyUp);\n    this.input.removeEventListener('wheel', this._handleWheel);\n  }\n\n  // --- DOM Event Handlers ---\n\n  /**\n   * Handle mousedown/touchstart on up button.\n   * @private\n   */\n  _handleUpMouseDown(e: MouseEvent | TouchEvent): void {\n    e.preventDefault();\n    this.startUpSpin();\n  }\n\n  /**\n   * Handle mousedown/touchstart on down button.\n   * @private\n   */\n  _handleDownMouseDown(e: MouseEvent | TouchEvent): void {\n    e.preventDefault();\n    this.startDownSpin();\n  }\n\n  /**\n   * Handle mouseup/touchend/mouseleave to stop spinning.\n   * @private\n   */\n  _handleMouseUp(_e: MouseEvent | TouchEvent): void {\n    this.stopSpin();\n  }\n\n  /**\n   * Handle keydown events on up button.\n   * @private\n   */\n  _handleUpKeyDown(e: KeyboardEvent): void {\n    // Only handle Enter and Space keys\n    if (e.keyCode === 13 || e.keyCode === 32) {\n      // Enter or Space\n      e.preventDefault();\n      // Ignore auto-repeat while holding the key\n      if (e.repeat) return;\n      this.startUpSpin();\n    }\n  }\n\n  /**\n   * Handle keyup events on up button.\n   * @private\n   */\n  _handleUpKeyUp(e: KeyboardEvent): void {\n    // Only handle Enter and Space keys\n    if (e.keyCode === 13 || e.keyCode === 32) {\n      // Enter or Space\n      this.stopSpin();\n    }\n  }\n\n  /**\n   * Handle keydown events on down button.\n   * @private\n   */\n  _handleDownKeyDown(e: KeyboardEvent): void {\n    // Only handle Enter and Space keys\n    if (e.keyCode === 13 || e.keyCode === 32) {\n      // Enter or Space\n      e.preventDefault();\n      // Ignore auto-repeat while holding the key\n      if (e.repeat) return;\n      this.startDownSpin();\n    }\n  }\n\n  /**\n   * Handle keyup events on down button.\n   * @private\n   */\n  _handleDownKeyUp(e: KeyboardEvent): void {\n    // Only handle Enter and Space keys\n    if (e.keyCode === 13 || e.keyCode === 32) {\n      // Enter or Space\n      this.stopSpin();\n    }\n  }\n\n  /**\n   * Sanitize value before other capture listeners observe unsanitized input.\n   * @private\n   */\n  _handleWindowChangeCapture(e: Event): void {\n    const target = e.target as HTMLInputElement | null;\n    if (!target || target !== this.input) return;\n    const currentValue = this.getValue();\n    if (!Number.isFinite(currentValue)) return;\n    const sanitized = this._applyConstraints(currentValue);\n    if (sanitized !== currentValue) {\n      this._setDisplay(sanitized, false);\n    }\n  }\n\n  /**\n   * Handle keydown events on the input element.\n   * @private\n   */\n  _handleKeyDown(e: KeyboardEvent): void {\n    switch (e.key) {\n      case 'ArrowUp':\n        e.preventDefault();\n        if (e.repeat) return; // ignore auto-repeat\n        this.startUpSpin();\n        break;\n      case 'ArrowDown':\n        e.preventDefault();\n        if (e.repeat) return; // ignore auto-repeat\n        this.startDownSpin();\n        break;\n      case 'Enter':\n        this._checkValue(false);\n        break;\n    }\n  }\n\n  /**\n   * Handle keyup events on the input element.\n   * @private\n   */\n  _handleKeyUp(e: KeyboardEvent): void {\n    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {\n      this.stopSpin();\n    }\n  }\n\n  /**\n   * Handle wheel events on the input element.\n   * @private\n   */\n  _handleWheel(e: WheelEvent): void {\n    if (!this.settings.mousewheel) {\n      return;\n    }\n\n    if (document.activeElement === this.input) {\n      e.preventDefault();\n      if (e.deltaY < 0) {\n        this.upOnce();\n      } else if (e.deltaY > 0) {\n        this.downOnce();\n      }\n    }\n  }\n\n  /**\n   * Set up mutation observer to watch for disabled/readonly attribute changes\n   * @private\n   */\n  _setupMutationObserver(): void {\n    if (typeof MutationObserver !== 'undefined') {\n      this._mutationObserver = new MutationObserver((mutations) => {\n        mutations.forEach((mutation) => {\n          if (mutation.type === 'attributes') {\n            if (mutation.attributeName === 'disabled' || mutation.attributeName === 'readonly') {\n              this._updateButtonDisabledState();\n            } else if (\n              mutation.attributeName === 'min' ||\n              mutation.attributeName === 'max' ||\n              mutation.attributeName === 'step'\n            ) {\n              this._syncSettingsFromNativeAttributes();\n            }\n          }\n        });\n      });\n\n      this._mutationObserver.observe(this.input, {\n        attributes: true,\n        attributeFilter: ['disabled', 'readonly', 'min', 'max', 'step'],\n      });\n    }\n  }\n\n  /**\n   * Update button disabled state based on input disabled/readonly state\n   * @private\n   */\n  _updateButtonDisabledState(): void {\n    const isDisabled = this.input.disabled || this.input.hasAttribute('readonly');\n\n    if (this._upButton) {\n      this._upButton.disabled = isDisabled;\n    }\n    if (this._downButton) {\n      this._downButton.disabled = isDisabled;\n    }\n\n    if (isDisabled) {\n      this.stopSpin();\n    }\n  }\n\n  /**\n   * Check if callbacks are properly paired and warn if not\n   * @private\n   */\n  _checkCallbackPairing(): void {\n    const defCb = (v: string) => v;\n    const hasBefore =\n      this.settings.callback_before_calculation &&\n      this.settings.callback_before_calculation.toString() !== defCb.toString();\n    const hasAfter =\n      this.settings.callback_after_calculation &&\n      this.settings.callback_after_calculation.toString() !== defCb.toString();\n\n    if (hasBefore && !hasAfter) {\n      console.warn(\n        'TouchSpin: callback_before_calculation is defined but callback_after_calculation is missing. ' +\n          'These callbacks should be used together - one removes formatting, the other adds it back.'\n      );\n    } else if (!hasBefore && hasAfter) {\n      console.warn(\n        'TouchSpin: callback_after_calculation is defined but callback_before_calculation is missing. ' +\n          'These callbacks should be used together - one removes formatting, the other adds it back.'\n      );\n    }\n\n    // If both callbacks are defined, validate that they properly reverse each other\n    if (hasBefore && hasAfter) {\n      // Build smart test values based on configuration\n      const testValues: string[] = [];\n      const decimals = this.settings.decimals || 0;\n      const formatValue = (n: number) => n.toFixed(decimals);\n\n      const hasMin = this.settings.min !== null && this.settings.min !== undefined;\n      const hasMax = this.settings.max !== null && this.settings.max !== undefined;\n\n      if (hasMin && hasMax) {\n        // Test with both boundary values\n        testValues.push(formatValue(this.settings.min!), formatValue(this.settings.max!));\n      } else if (hasMin) {\n        // Test only with min value\n        testValues.push(formatValue(this.settings.min!));\n      } else if (hasMax) {\n        // Test only with max value\n        testValues.push(formatValue(this.settings.max!));\n      } else {\n        // No boundaries set - use single default value\n        testValues.push(decimals > 0 ? '50.55' : '50');\n      }\n\n      const failures: string[] = [];\n\n      for (const testValue of testValues) {\n        const afterResult = this.settings.callback_after_calculation?.(testValue);\n        if (afterResult === undefined) continue;\n\n        const beforeResult = this.settings.callback_before_calculation?.(afterResult);\n        if (beforeResult === undefined) continue;\n\n        if (beforeResult !== testValue) {\n          failures.push(\n            `\"${testValue}\" → after → \"${afterResult}\" → before → \"${beforeResult}\" (expected \"${testValue}\")`\n          );\n        }\n      }\n\n      if (failures.length > 0) {\n        console.warn(\n          'TouchSpin: Callbacks are not properly paired - round-trip test failed:\\n' +\n            failures.join('\\n') +\n            '\\n' +\n            'callback_before_calculation must reverse what callback_after_calculation does. ' +\n            'For example, if after adds \" USD\", before must strip \" USD\".'\n        );\n      }\n    }\n  }\n}\n\nexport interface TouchSpinCorePublicAPI {\n  upOnce: () => void;\n  downOnce: () => void;\n  startUpSpin: () => void;\n  startDownSpin: () => void;\n  stopSpin: () => void;\n  updateSettings: (opts: Partial<TouchSpinCoreOptions>) => void;\n  getValue: () => number;\n  setValue: (v: number | string) => void;\n  destroy: () => void;\n  initDOMEventHandling: () => void;\n  registerTeardown: (callback: () => void) => () => void;\n  attachUpEvents: (el: HTMLElement | null) => void;\n  attachDownEvents: (el: HTMLElement | null) => void;\n  observeSetting: <K extends keyof TouchSpinCoreOptions>(\n    key: K,\n    cb: (value: NonNullable<TouchSpinCoreOptions[K]>, prev?: TouchSpinCoreOptions[K]) => void\n  ) => () => void;\n}\n\n/**\n * Initialize TouchSpin on an input element or get existing instance.\n * @param {HTMLInputElement} inputEl\n * @param {Partial<TouchSpinCoreOptions>=} opts\n * @returns {TouchSpinCorePublicAPI|null}\n */\nexport function TouchSpin(\n  inputEl: HTMLInputElement,\n  opts?: Partial<TouchSpinCoreOptions>\n): TouchSpinCorePublicAPI | null {\n  // Check if element is an input (graceful handling for public API)\n  if (!inputEl || inputEl.nodeName !== 'INPUT') {\n    console.warn('Must be an input.');\n    return null;\n  }\n\n  // If options provided, initialize/reinitialize\n  if (opts !== undefined) {\n    // Destroy existing instance if it exists (destroy() removes itself from element)\n    if ((inputEl as WithCoreElement)[INSTANCE_KEY]) {\n      console.warn(\n        'TouchSpin: Destroying existing instance and reinitializing. Consider using updateSettings() instead.'\n      );\n      (inputEl as WithCoreElement)[INSTANCE_KEY]?.destroy();\n    }\n\n    // Create new instance and store on element\n    const core = new TouchSpinCore(inputEl, opts);\n    (inputEl as WithCoreElement)[INSTANCE_KEY] = core;\n\n    // Initialize DOM event handling\n    core.initDOMEventHandling();\n\n    return core.toPublicApi();\n  }\n\n  // No options - return existing instance or create with defaults\n  if (!(inputEl as WithCoreElement)[INSTANCE_KEY]) {\n    const core = new TouchSpinCore(inputEl, {});\n    (inputEl as WithCoreElement)[INSTANCE_KEY] = core;\n    core.initDOMEventHandling();\n    return core.toPublicApi();\n  }\n\n  return ((inputEl as WithCoreElement)[INSTANCE_KEY] as TouchSpinCore).toPublicApi();\n}\n\n/**\n * Get existing TouchSpin instance from input element (without creating one).\n * @param {HTMLInputElement} inputEl\n * @returns {TouchSpinCorePublicAPI|null}\n */\nexport function getTouchSpin(inputEl: HTMLInputElement): TouchSpinCorePublicAPI | null {\n  return (inputEl as WithCoreElement)[INSTANCE_KEY]\n    ? ((inputEl as WithCoreElement)[INSTANCE_KEY] as TouchSpinCore).toPublicApi()\n    : null;\n}\n\n/**\n * Create and return a plain public API bound to a new core instance.\n * @param {HTMLInputElement} inputEl\n * @param {Partial<TouchSpinCoreOptions>=} opts\n * @returns {TouchSpinCorePublicAPI}\n * @deprecated Use TouchSpin() instead\n */\nexport function createPublicApi(\n  inputEl: HTMLInputElement,\n  opts?: Partial<TouchSpinCoreOptions>\n): TouchSpinCorePublicAPI | null {\n  return TouchSpin(inputEl, opts);\n}\n\n/** Event name constants for wrappers to map/bridge. */\nexport const CORE_EVENTS = Object.freeze({\n  MIN: 'min',\n  MAX: 'max',\n  START_SPIN: 'startspin',\n  START_UP: 'startupspin',\n  START_DOWN: 'startdownspin',\n  STOP_SPIN: 'stopspin',\n  STOP_UP: 'stopupspin',\n  STOP_DOWN: 'stopdownspin',\n} as const);\n\n/**\n * Convenience helper to attach core to an input element.\n * @param {HTMLInputElement} inputEl\n * @param {Partial<TouchSpinCoreOptions>=} opts\n * @returns {TouchSpinCore}\n */\nexport function attach(\n  inputEl: HTMLInputElement,\n  opts?: Partial<TouchSpinCoreOptions>\n): TouchSpinCore {\n  return new TouchSpinCore(inputEl, opts ?? {});\n}\n\nexport default TouchSpinCore;\n\n// Note: AbstractRendererBase is not exported as it's only needed by renderer implementations\n// Renderers should import it directly: import AbstractRendererBase from '../../../core/src/AbstractRendererBase.js';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSO,IAAK,yBAAL,kBAAKA,4BAAL;AAELA,0BAAA,iBAAA,IAAkB;AAGlBA,0BAAA,SAAA,IAAU;AAGVA,0BAAA,WAAA,IAAY;AAGZA,0BAAA,eAAA,IAAgB;AAGhBA,0BAAA,iBAAA,IAAkB;AAGlBA,0BAAA,WAAA,IAAY;AAGZA,0BAAA,SAAA,IAAU;AApBA,SAAAA;AAAA,GAAA,0BAAA,CAAA,CAAA;AA2BL,IAAK,wBAAL,kBAAKC,2BAAL;AAELA,yBAAA,QAAA,IAAS;AAGTA,yBAAA,QAAA,IAAS;AAGTA,yBAAA,eAAA,IAAgB;AAGhBA,yBAAA,cAAA,IAAe;AAGfA,yBAAA,kBAAA,IAAmB;AAGnBA,yBAAA,oBAAA,IAAqB;AAGrBA,yBAAA,iBAAA,IAAkB;AAGlBA,yBAAA,mBAAA,IAAoB;AAGpBA,yBAAA,iBAAA,IAAkB;AA1BR,SAAAA;AAAA,GAAA,yBAAA,CAAA,CAAA;ACsyDL,IAAM,cAAc,OAAO,OAAO;EACvC,KAAK;EACL,KAAK;EACL,YAAY;EACZ,UAAU;EACV,YAAY;EACZ,WAAW;EACX,SAAS;EACT,WAAW;AACb,CAAU;;;AFnzDH,SAAS,qBACd,GACA,SACA;AAEA,IAAE,GAAG,YAAY,SAA4B,SAAmB,KAAwB;AAEtF,QAAI,OAAO,YAAY,UAAU;AAC/B,YAAM,MAAM,OAAO,OAAO,EAAE,YAAY;AACxC,UAAI;AAEJ,WAAK,KAAK,WAAyB;AACjC,cAAM,UAAU;AAChB,cAAM,OAAQ,QAAgE;AAG9E,aAAK,QAAQ,cAAc,QAAQ,UAAU,QAAQ,QAAW;AAC9D,cAAI,MAAM;AACR,kBAAM,KAAK,SAAS;AAAA,UACtB,OAAO;AAEL,kBAAM,QAAQ;AAAA,UAChB;AACA;AAAA,QACF;AAEA,YAAI,CAAC,KAAM;AAEX,gBAAQ,KAAK;AAAA,UACX,KAAK;AACH,iBAAK,QAAQ;AACb,mBAAQ,QAAgE;AACxE;AAAA,UACF,KAAK;AACH,iBAAK,OAAO;AACZ;AAAA,UACF,KAAK;AACH,iBAAK,SAAS;AACd;AAAA,UACF,KAAK;AACH,iBAAK,YAAY;AACjB;AAAA,UACF,KAAK;AACH,iBAAK,cAAc;AACnB;AAAA,UACF,KAAK;AACH,iBAAK,SAAS;AACd;AAAA,UACF,KAAK;AACH,iBAAK,eAAgB,OAAmC,CAAC,CAAC;AAC1D;AAAA,UACF,KAAK;AAAA,UACL,KAAK;AACH,iBAAK,SAAS,GAAsB;AACpC;AAAA,QACJ;AAAA,MACF,CAAC;AAED,aAAO,QAAQ,SAAY,OAAQ;AAAA,IACrC;AAGA,WAAO,KAAK,KAAK,WAAyB;AACxC,YAAM,SAAS,EAAE,IAAe;AAChC,YAAM,UAAU;AAGhB,UAAI,OAAsC;AAC1C,UAAI;AACF,eAAO,QAAQ,SAAU,WAA4B,CAAC,CAAC;AAAA,MACzD,SAAS,QAAQ;AAGf;AAAA,MACF;AAGA,UAAI,CAAC,MAAM;AACT;AAAA,MACF;AAGA,MAAC,QAA+D,cAAc;AAG9E,YAAM,iBAAiB,MAAM;AAG3B,eAAO;AAAA,UACL,GAAG,uBAAuB,OAAO,IAAI,uBAAuB,SAAS,IAAI,uBAAuB,aAAa,IAAI,uBAAuB,eAAe,IAAI,uBAAuB,SAAS,IAAI,uBAAuB,eAAe,IAAI,uBAAuB,OAAO;AAAA,QACzQ;AAAA,MACF;AAGA,WAAK,iBAAiB,cAAc;AAGpC,aAAO,GAAG,uBAAuB,SAAS,MAAM;AAC9C,cAAM,MAAO,QAAgE;AAC7E,YAAI,IAAK,KAAI,OAAO;AAAA,MACtB,CAAC;AACD,aAAO,GAAG,uBAAuB,WAAW,MAAM;AAChD,cAAM,MAAO,QAAgE;AAC7E,YAAI,IAAK,KAAI,SAAS;AAAA,MACxB,CAAC;AACD,aAAO,GAAG,uBAAuB,eAAe,MAAM;AACpD,cAAM,MAAO,QAAgE;AAC7E,YAAI,IAAK,KAAI,YAAY;AAAA,MAC3B,CAAC;AACD,aAAO,GAAG,uBAAuB,iBAAiB,MAAM;AACtD,cAAM,MAAO,QAAgE;AAC7E,YAAI,IAAK,KAAI,cAAc;AAAA,MAC7B,CAAC;AACD,aAAO,GAAG,uBAAuB,WAAW,MAAM;AAChD,cAAM,MAAO,QAAgE;AAC7E,YAAI,IAAK,KAAI,SAAS;AAAA,MACxB,CAAC;AACD,aAAO,GAAG,uBAAuB,iBAAiB,CAAC,IAAa,MAAe;AAC7E,cAAM,MAAO,QAAgE;AAC7E,YAAI,IAAK,KAAI,eAAgB,KAAiC,CAAC,CAAC;AAAA,MAClE,CAAC;AACD,aAAO,GAAG,uBAAuB,SAAS,MAAM;AAE9C,cAAM,MAAO,QAAgE;AAC7E,YAAI,KAAK;AACP,cAAI,QAAQ;AACZ,iBAAQ,QAAgE;AAAA,QAC1E;AAAA,MACF,CAAC;AAID,aAAO,GAAG,kBAAkB,MAAM;AApKxC;AAqKQ,cAAM,OAAQ,QAA+C;AAG7D,2CAAM,gBAAN,8BAAoB;AAAA,MACtB,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AACF;AAMO,SAAS,YACd,SACM;AACN,QAAM,IAAK,WAAoD;AAC/D,MAAI,GAAG;AACL,yBAAqB,GAAG,OAAO;AAAA,EACjC,OAAO;AACL,YAAQ;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACF;","names":["TouchSpinCallableEvent","TouchSpinEmittedEvent"]}