// ============================================================================
// Stylescape | Number Stepper Manager
// ============================================================================
// Wires the decrement/increment buttons of a `.ss-c-stepper` to its number
// input. The input itself keeps working without JavaScript (typing, arrow
// keys); the buttons step it and are disabled at min/max.
// ============================================================================
/**
* Configuration options for NumberStepperManager
*/
export interface NumberStepperOptions {
/** Selector for the decrement button */
downSelector?: string;
/** Selector for the increment button */
upSelector?: string;
/** Callback after a button changed the value */
onStep?: (value: number, input: HTMLInputElement) => void;
}
/**
* Stepper buttons for a number input.
*
* @example JavaScript
* ```typescript
* new NumberStepperManager(".ss-c-stepper", {
* onStep: (value) => console.log(value),
* })
* ```
*
* @example HTML with data-ss
* ```html
*
*
*
*
*
* ```
*/
export class NumberStepperManager {
private element: HTMLElement | null;
private input: HTMLInputElement | null = null;
private down: HTMLButtonElement | null = null;
private up: HTMLButtonElement | null = null;
private options: Required;
constructor(
selectorOrElement: string | HTMLElement,
options: NumberStepperOptions = {},
) {
this.element =
typeof selectorOrElement === "string"
? document.querySelector(selectorOrElement)
: selectorOrElement;
this.options = {
downSelector: options.downSelector ?? '[data-ss-stepper="down"]',
upSelector: options.upSelector ?? '[data-ss-stepper="up"]',
onStep: options.onStep ?? (() => {}),
};
if (!this.element) {
console.warn(
"[Stylescape] NumberStepperManager element not found",
);
return;
}
this.init();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Step the value down (negative) or up (positive) by `count` steps.
*/
public step(count: number): void {
const input = this.input;
if (!input || input.disabled || input.readOnly || count === 0) return;
const before = input.value;
try {
if (count > 0) input.stepUp(count);
else input.stepDown(-count);
} catch {
// stepUp/stepDown throw on a non-numeric value; start from min
// (or 0) instead, as a spin button would.
input.value = input.min !== "" ? input.min : "0";
}
if (input.value === before) return;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
this.options.onStep(input.valueAsNumber, input);
this.sync();
}
/**
* Destroy the manager
*/
public destroy(): void {
this.down?.removeEventListener("click", this.handleDown);
this.up?.removeEventListener("click", this.handleUp);
this.input?.removeEventListener("input", this.sync);
this.input?.removeEventListener("change", this.sync);
this.element = null;
this.input = null;
this.down = null;
this.up = null;
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
if (!this.element) return;
this.input =
this.element instanceof HTMLInputElement
? this.element
: this.element.querySelector(
'input[type="number"]',
);
if (!this.input) {
console.warn("[Stylescape] NumberStepperManager: no number input");
return;
}
this.down = this.element.querySelector(
this.options.downSelector,
);
this.up = this.element.querySelector(
this.options.upSelector,
);
[this.down, this.up].forEach((button) => {
if (!button) return;
if (!button.hasAttribute("type")) button.type = "button";
if (this.input?.id) {
button.setAttribute("aria-controls", this.input.id);
}
});
this.down?.addEventListener("click", this.handleDown);
this.up?.addEventListener("click", this.handleUp);
this.input.addEventListener("input", this.sync);
this.input.addEventListener("change", this.sync);
this.sync();
}
private handleDown = (): void => this.step(-1);
private handleUp = (): void => this.step(1);
/** Mark a button unavailable when its direction is out of range. */
private sync = (): void => {
const input = this.input;
if (!input) return;
const value = input.valueAsNumber;
const min = input.min === "" ? -Infinity : Number(input.min);
const max = input.max === "" ? Infinity : Number(input.max);
const off = input.disabled || input.readOnly;
this.setLimit(
this.down,
off || (!Number.isNaN(value) && value <= min),
);
this.setLimit(this.up, off || (!Number.isNaN(value) && value >= max));
};
// aria-disabled rather than `disabled`: a disabled button would drop
// keyboard focus the moment the limit is reached.
private setLimit(
button: HTMLButtonElement | null,
atLimit: boolean,
): void {
if (!button) return;
if (atLimit) button.setAttribute("aria-disabled", "true");
else button.removeAttribute("aria-disabled");
}
}
export default NumberStepperManager;