// ============================================================================
// Stylescape | Rating Manager
// ============================================================================
// Manages star rating UI components with accessibility support.
// Supports data-ss-rating attributes for declarative configuration.
// ============================================================================
/**
* Configuration options for RatingManager
*/
export interface RatingManagerOptions {
/** Maximum rating value */
max?: number;
/** Initial rating value */
value?: number;
/** Whether rating is read-only */
readOnly?: boolean;
/** Allow half ratings */
half?: boolean;
/** Selector for star elements within container */
starSelector?: string;
/** CSS class for active/filled stars */
activeClass?: string;
/** CSS class for half-filled stars */
halfClass?: string;
/** CSS class for hover state */
hoverClass?: string;
/** Callback when rating changes */
onChange?: (value: number) => void;
}
/**
* Star rating manager with keyboard accessibility.
*
* @example JavaScript
* ```typescript
* const rating = new RatingManager("#rating", {
* max: 5,
* value: 3,
* onChange: (value) => console.log(`Rated: ${value}`)
* })
* ```
*
* @example HTML with data-ss
* ```html
*
* ★
* ★
* ★
* ★
* ★
*
* ```
*/
export class RatingManager {
private container: HTMLElement | null;
private stars: HTMLElement[];
private options: Required;
private currentValue: number;
private hoverValue: number | null = null;
constructor(
selectorOrElement: string | HTMLElement,
options: RatingManagerOptions = {},
) {
this.container =
typeof selectorOrElement === "string"
? document.querySelector(selectorOrElement)
: selectorOrElement;
this.options = {
max: options.max ?? 5,
value: options.value ?? 0,
readOnly: options.readOnly ?? false,
half: options.half ?? false,
starSelector: options.starSelector ?? ".star, [data-value]",
activeClass: options.activeClass ?? "rating__star--active",
halfClass: options.halfClass ?? "rating__star--half",
hoverClass: options.hoverClass ?? "rating__star--hover",
onChange: options.onChange ?? (() => {}),
};
this.currentValue = this.options.value;
this.stars = [];
if (!this.container) {
console.warn("[Stylescape] RatingManager container not found");
return;
}
this.init();
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Get current rating value
*/
public getValue(): number {
return this.currentValue;
}
/**
* Set rating value
*/
public setValue(value: number): void {
const clamped = Math.min(Math.max(value, 0), this.options.max);
this.currentValue = this.options.half ? clamped : Math.round(clamped);
this.updateDisplay();
this.options.onChange(this.currentValue);
}
/**
* Reset rating to 0
*/
public reset(): void {
this.setValue(0);
}
/**
* Set read-only state
*/
public setReadOnly(readOnly: boolean): void {
this.options.readOnly = readOnly;
this.container?.classList.toggle("rating--readonly", readOnly);
if (readOnly) {
this.container?.setAttribute("aria-readonly", "true");
} else {
this.container?.removeAttribute("aria-readonly");
}
}
/**
* Destroy the rating manager
*/
public destroy(): void {
this.stars.forEach((star) => {
star.removeEventListener("click", this.handleClick);
star.removeEventListener("mouseenter", this.handleMouseEnter);
star.removeEventListener("mouseleave", this.handleMouseLeave);
});
this.container?.removeEventListener("keydown", this.handleKeyDown);
this.container = null;
this.stars = [];
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
if (!this.container) return;
// Get or create stars
this.stars = Array.from(
this.container.querySelectorAll(
this.options.starSelector,
),
);
// If no stars found, create them
if (this.stars.length === 0) {
this.createStars();
}
// Set ARIA attributes
this.container.setAttribute("role", "slider");
this.container.setAttribute("aria-valuemin", "0");
this.container.setAttribute("aria-valuemax", String(this.options.max));
this.container.setAttribute(
"aria-valuenow",
String(this.currentValue),
);
this.container.setAttribute("tabindex", "0");
// Add event listeners
if (!this.options.readOnly) {
this.stars.forEach((star, index) => {
star.addEventListener("click", this.handleClick);
star.addEventListener("mouseenter", this.handleMouseEnter);
star.addEventListener("mouseleave", this.handleMouseLeave);
// Set data-value if not present
if (!star.hasAttribute("data-value")) {
star.setAttribute("data-value", String(index + 1));
}
});
this.container.addEventListener("keydown", this.handleKeyDown);
this.container.addEventListener("mouseleave", () => {
this.hoverValue = null;
this.updateDisplay();
});
}
this.updateDisplay();
}
private createStars(): void {
if (!this.container) return;
for (let i = 1; i <= this.options.max; i++) {
const star = document.createElement("span");
star.className = "rating__star";
star.setAttribute("data-value", String(i));
star.textContent = "★";
this.container.appendChild(star);
this.stars.push(star);
}
}
private handleClick = (e: Event): void => {
if (this.options.readOnly) return;
const star = e.currentTarget as HTMLElement;
const value = this.getStarValue(star, e as MouseEvent);
this.setValue(value);
};
private handleMouseEnter = (e: Event): void => {
if (this.options.readOnly) return;
const star = e.currentTarget as HTMLElement;
this.hoverValue = this.getStarValue(star, e as MouseEvent);
this.updateDisplay();
};
private handleMouseLeave = (): void => {
// Handled by container mouseleave
};
private handleKeyDown = (e: KeyboardEvent): void => {
if (this.options.readOnly) return;
const step = this.options.half ? 0.5 : 1;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
e.preventDefault();
this.setValue(
Math.min(this.currentValue + step, this.options.max),
);
break;
case "ArrowLeft":
case "ArrowDown":
e.preventDefault();
this.setValue(Math.max(this.currentValue - step, 0));
break;
case "Home":
e.preventDefault();
this.setValue(0);
break;
case "End":
e.preventDefault();
this.setValue(this.options.max);
break;
}
};
private getStarValue(star: HTMLElement, event: MouseEvent): number {
const baseValue = parseInt(star.getAttribute("data-value") || "0", 10);
if (this.options.half) {
const rect = star.getBoundingClientRect();
const isHalf = event.clientX < rect.left + rect.width / 2;
return isHalf ? baseValue - 0.5 : baseValue;
}
return baseValue;
}
private updateDisplay(): void {
const displayValue = this.hoverValue ?? this.currentValue;
this.stars.forEach((star) => {
const value = parseInt(star.getAttribute("data-value") || "0", 10);
const isActive = value <= displayValue;
const isHalf = this.options.half && value - 0.5 === displayValue;
const isHover = this.hoverValue !== null;
star.classList.toggle(
this.options.activeClass,
isActive && !isHalf,
);
star.classList.toggle(this.options.halfClass, isHalf);
star.classList.toggle(
this.options.hoverClass,
isHover && value <= displayValue,
);
});
// Update ARIA
this.container?.setAttribute(
"aria-valuenow",
String(this.currentValue),
);
}
}
export default RatingManager;