import {classMap} from "lit/directives/class-map.js"; import {type CSSResultGroup, html, unsafeCSS} from 'lit'; import {defaultValue} from "../../internal/default-value"; import {FormControlController, validValidityState} from "../../internal/form"; import {HasSlotController} from "../../internal/slot"; import {property, query, state} from 'lit/decorators.js'; import {styleMap} from 'lit/directives/style-map.js'; import {unsafeHTML} from "lit/directives/unsafe-html.js"; import ZincElement from '../../internal/zinc-element'; import type {ZincFormControl} from '../../internal/zinc-element'; import formControlStyles from '../../form-control.scss'; import styles from './rating.scss'; /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/rating * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - The default slot. * @slot example - An example slot. * @slot help-text - Text that describes how to use the rating. Alternatively, you can use the `help-text` attribute. * * @csspart form-control - The form control that wraps the symbols and help text. * @csspart form-control-help-text - The help text's wrapper. * @csspart base - The component's base wrapper. * @csspart preview - The value shown next to the symbols when `preview` is enabled. * * @cssproperty --example - An example CSS custom property. * @cssproperty --preview-color - The color of the preview value. * @cssproperty --preview-size - The font size of the preview value. */ export default class ZnRating extends ZincElement implements ZincFormControl { static styles: CSSResultGroup = [unsafeCSS(formControlStyles), unsafeCSS(styles)]; private readonly formControlController = new FormControlController(this, { assumeInteractionOn: ['zn-blur', 'zn-input'] }); private readonly hasSlotController = new HasSlotController(this, 'help-text'); @query('.rating') rating: HTMLElement; @query('.rating__symbols') symbols: HTMLElement; @state() private hoverValue: number = 0; @state() private isHovering: boolean = false; @property() label: string; /** The rating's help text. If you need to display HTML, use the `help-text` slot instead. */ @property({attribute: 'help-text'}) helpText: string = ''; @property() name: string; @property({type: Number}) value: number = 0; /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue: number = 0; @property({type: Number}) max: number = 5; @property({type: Number}) precision: number = 1; @property({type: Boolean}) readonly: boolean = false; @property({type: Boolean}) disabled: boolean = false; /** Displays the value alongside the symbols, updating live as the pointer moves across them. */ @property({type: Boolean}) preview: boolean = false; @property({}) size: 'small' | 'medium' | 'large' = 'medium'; @property() getSymbol: (value: number) => string = () => ''; /** Gets the validity state object */ get validity() { return validValidityState; } /** Gets the validation message */ get validationMessage() { return ""; } /** Checks the validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */ checkValidity(): boolean { return true; } /** Gets the associated form, if one exists. */ getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return true; } /** Sets a custom validation message. Pass an empty string to restore validity. */ setCustomValidity() { this.formControlController.updateValidity(); } private _roundToPrecision(value: number, precision: number): number { const factor = 1 / precision; return Math.ceil(value * factor) / factor; } private _getValueFromXCoordinate(coordinate: number): number { const {left, width} = this.symbols.getBoundingClientRect(); // Symbols are spaced with a gap rather than padding, so the symbols only cover part of the // container and a plain width-to-value ratio would skew fractions within each symbol. const gap = parseFloat(getComputedStyle(this.symbols).columnGap) || 0; const symbolWidth = (width - gap * (this.max - 1)) / this.max; const stride = symbolWidth + gap; const position = Math.min(Math.max(coordinate - left, 0), width); const index = Math.min(Math.floor(position / stride), this.max - 1); // Landing in a gap reads as the preceding symbol being complete. const fraction = Math.min((position - index * stride) / symbolWidth, 1); const value = this._roundToPrecision(index + fraction, this.precision); return Math.min(Math.max(value, 0), this.max); } private _formatValue(value: number): string { const decimals = String(this.precision).split('.')[1]?.length ?? 0; return value.toFixed(decimals); } private _getValueFromMousePosition(event: MouseEvent): number { return this._getValueFromXCoordinate(event.clientX); } private _getValueFromTouchPosition(event: TouchEvent): number { return this._getValueFromXCoordinate(event.touches[0].clientX); } private _setValue(value: number) { if (this.disabled || this.readonly) { return; } this.value = value === this.value ? 0 : value; this.isHovering = false; } private _handleClick(event: MouseEvent) { if (this.readonly || this.disabled) { return; } this._setValue(this._getValueFromMousePosition(event)); } private _handleMouseEnter(event: MouseEvent) { this.isHovering = true; this.hoverValue = this._getValueFromMousePosition(event); } private _handleMouseMove(event: MouseEvent) { this.hoverValue = this._getValueFromMousePosition(event); } private _handleMouseLeave() { this.isHovering = false; } private _handleTouchStart(event: TouchEvent) { this.isHovering = true; this.hoverValue = this._getValueFromTouchPosition(event); } private _handleTouchMove(event: TouchEvent) { this.hoverValue = this._getValueFromTouchPosition(event); } private _handleTouchEnd(event: TouchEvent) { this.isHovering = false; // `touches` is empty once the finger lifts, so the final position lives in `changedTouches`. this._setValue(this._getValueFromXCoordinate(event.changedTouches[0].clientX)); event.preventDefault(); } render() { const counter = Array.from(Array(this.max).keys()); const hasHelpText = this.helpText ? true : this.hasSlotController.test('help-text'); let displayValue = 0; if (this.disabled || this.readonly) { displayValue = this.value; } else { displayValue = this.isHovering ? this.hoverValue : this.value; } return html`
${counter.map(index => { if (displayValue > index && displayValue < index + 1) { return html`
${unsafeHTML(this.getSymbol(index + 1))}
${unsafeHTML(this.getSymbol(index + 1))}
`; } return html` = index + 1 })} role="presentation"> ${unsafeHTML(this.getSymbol(index + 1))} `; })}
${this.preview ? html` ` : ''}
${this.helpText}
`; } }