/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import KTData from '../../helpers/data'; import KTComponent from '../component'; import { KTRatingConfigInterface, KTRatingInterface } from './types'; declare global { interface Window { KTRating: typeof KTRating; } } const STAR_SVG = ''; const HEART_SVG = ''; export class KTRating extends KTComponent implements KTRatingInterface { protected override _name: string = 'rating'; protected override _defaultConfig: KTRatingConfigInterface = { value: 0, max: 5, readonly: false, name: 'rating', symbol: 'star', lazy: false, }; protected override _config: KTRatingConfigInterface = this._defaultConfig; protected _container: HTMLElement | null = null; protected _changeListener: ((e: Event) => void) | null = null; constructor( element: HTMLElement, config: KTRatingConfigInterface | null = null, ) { super(); if (KTData.has(element as HTMLElement, this._name)) return; this._init(element); this._buildConfig(config); this._render(); if (!this._config.readonly) { this._handlers(); } } protected _getMax(): number { const max = Number(this._getOption('max')); return Number.isFinite(max) && max >= 1 ? Math.floor(max) : 5; } protected _getValue(): number { const v = Number(this._getOption('value')); const max = this._getMax(); return Number.isFinite(v) && v >= 0 && v <= max ? Math.floor(v) : 0; } protected _render(): void { if (!this._element) return; const max = this._getMax(); const value = this._getValue(); const readonly = this._config.readonly === true; const symbol = (this._config.symbol as string) || 'star'; const name = (this._config.name as string) || 'rating'; const isStar = symbol !== 'heart'; const filledClass = isStar ? 'text-yellow-400 dark:text-yellow-600' : 'text-red-500 dark:text-red-500'; const unfilledClass = 'text-muted-foreground/50 dark:text-muted-foreground/50'; const path = isStar ? STAR_SVG : HEART_SVG; const svg = `${path}`; const container = document.createElement('div'); container.className = 'kt-rating flex flex-row-reverse justify-end items-center gap-0'; container.setAttribute('role', readonly ? 'img' : 'group'); container.setAttribute('aria-label', `Rating: ${value} of ${max}`); if (readonly) { container.setAttribute('aria-valuenow', String(value)); container.setAttribute('aria-valuemin', '0'); container.setAttribute('aria-valuemax', String(max)); } if (readonly) { for (let i = max; i >= 1; i--) { const filled = i <= value; const span = document.createElement('span'); span.className = filled ? filledClass : unfilledClass; span.innerHTML = svg; container.appendChild(span); } } else { container.setAttribute('aria-valuenow', String(value)); container.setAttribute('aria-valuemin', '1'); container.setAttribute('aria-valuemax', String(max)); for (let i = max; i >= 1; i--) { const id = `kt-rating-${this._uid}-${i}`; const radio = document.createElement('input'); radio.type = 'radio'; radio.name = name; radio.value = String(i); radio.id = id; radio.className = 'peer -ms-5 size-5 bg-transparent border-0 text-transparent cursor-pointer appearance-none checked:bg-none focus:bg-none focus:ring-0 focus:ring-offset-0'; radio.setAttribute('aria-label', `Rate ${i} of ${max}`); if (i === value) radio.checked = true; const label = document.createElement('label'); label.htmlFor = id; label.setAttribute('data-kt-rating-value', String(i)); label.className = `cursor-pointer kt-rating-label ${i <= value ? filledClass : unfilledClass}`; label.innerHTML = svg; container.appendChild(radio); container.appendChild(label); } this._updateInteractiveDisplay(); } this._element.innerHTML = ''; this._element.appendChild(container); this._container = container; } protected _updateInteractiveDisplay(): void { if (!this._container || this._config.readonly) return; const val = this.getValue(); const max = this._getMax(); const isStar = (this._config.symbol as string) !== 'heart'; const filledClass = isStar ? 'text-yellow-400 dark:text-yellow-600' : 'text-red-500 dark:text-red-500'; const unfilledClass = 'text-muted-foreground/50 dark:text-muted-foreground/50'; const filledTokens = filledClass.split(' '); const unfilledTokens = unfilledClass.split(' '); this._container .querySelectorAll('.kt-rating-label') .forEach((label) => { const v = parseInt( label.getAttribute('data-kt-rating-value') || '0', 10, ); const filled = v <= (val ?? 0); label.classList.remove(...filledTokens, ...unfilledTokens); label.classList.add(...(filled ? filledTokens : unfilledTokens)); }); this._container.setAttribute( 'aria-valuenow', val != null ? String(val) : '0', ); this._container.setAttribute('aria-label', `Rating: ${val ?? 0} of ${max}`); } protected _handlers(): void { if (!this._container) return; this._changeListener = () => { this._updateInteractiveDisplay(); const val = this.getValue(); this._fireEvent('change', { value: val }); this._dispatchEvent('kt.rating.change', { value: val }); }; this._container.addEventListener('change', this._changeListener); } public getValue(): number | null { if (!this._element) return null; if (this._config.readonly) { const v = this._getValue(); return v > 0 ? v : null; } const radio = this._container?.querySelector( 'input[type="radio"]:checked', ); if (!radio) return null; const n = parseInt(radio.value, 10); return Number.isFinite(n) ? n : null; } public setValue(value: number | null): void { if (!this._container) return; const max = this._getMax(); if (this._config.readonly) return; if (value !== null && value >= 1 && value <= max) { const radio = this._container.querySelector( `input[type="radio"][value="${value}"]`, ); if (radio) { radio.checked = true; this._updateInteractiveDisplay(); } } else { this._container .querySelectorAll('input[type="radio"]') .forEach((r) => { r.checked = false; }); this._updateInteractiveDisplay(); } } public override dispose(): void { if (this._container && this._changeListener) { this._container.removeEventListener('change', this._changeListener); this._changeListener = null; } this._container = null; super.dispose(); } public static getInstance(element: HTMLElement): KTRating | null { if (!element) return null; if (KTData.has(element, 'rating')) { return KTData.get(element, 'rating') as KTRating; } if (element.getAttribute('data-kt-rating') !== null) { return new KTRating(element); } return null; } public static createInstances(): void { const elements = document.querySelectorAll('[data-kt-rating]'); elements.forEach((el) => { if (el.getAttribute('data-kt-rating-lazy') === 'true') return; new KTRating(el); }); } public static init(): void { KTRating.createInstances(); } } if (typeof window !== 'undefined') { window.KTRating = KTRating; }