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._formatValue(displayValue)}
`
: ''}
${this.helpText}
`;
}
}