) {
super.updated(changedProperties);
if (changedProperties.has('value')) {
this._syncFormValue();
this._syncValidity();
}
if (
changedProperties.has('value') ||
changedProperties.has('readonly') ||
changedProperties.has('required') ||
changedProperties.has('invalid')
) {
this._syncStates();
}
}
/**
* FACE lifecycle: called when the parent form is reset.
* Restores rating to 0 (no selection).
*/
override formResetCallback(): void {
this.value = 0;
this._internals.setFormValue(null);
this._internals.setValidity({});
this._syncStates();
}
/**
* FACE lifecycle: called on session restore or browser autofill.
* Restores the rating value from the previously saved form state.
* The state is a numeric string (e.g. "3" or "3.5"), or null for no rating.
*/
override formStateRestoreCallback(
state: File | string | FormData | null,
_mode: 'restore' | 'autocomplete'
): void {
this.value = typeof state === 'string' ? parseFloat(state) : 0;
this._syncFormValue();
this._syncValidity();
this._syncStates();
}
/**
* Sync CustomStateSet states so :state() pseudo-classes work from external CSS.
*
* Must be called AFTER _syncValidity() so that :state(invalid) reads the
* freshly-updated _internals.validity.valid value.
*
* Exposed states:
* :state(readonly) — rating is read-only
* :state(required) — rating is required
* :state(invalid) — FACE constraint validation is failing
*/
private _syncStates(): void {
this._setState('readonly', this.readonly);
this._setState('required', this.required);
this._setState('invalid', !this._internals.validity.valid);
}
// ─── End FACE ─────────────────────────────────────────────────────────────
connectedCallback() {
super.connectedCallback();
this.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('keydown', this.handleKeyDown);
this.removeGlobalPointerListeners();
}
static styles = [
formControlStyles,
css`
:host {
display: block;
line-height: 1;
}
.rating {
display: inline-flex;
gap: var(--ag-space-1);
align-items: center;
cursor: pointer;
}
:host([readonly]) .rating {
cursor: default;
pointer-events: none;
}
/* Default (≈ Chakra UI “xs”) */
.star {
display: inline-flex;
align-items: center;
justify-content: center;
transition:
transform var(--ag-motion-medium) ease,
opacity var(--ag-motion-medium);
color: var(--ag-neutral-300);
width: var(--ag-space-3); /* 0.75rem → xs */
height: var(--ag-space-3);
}
/* size="sm" → Chakra UI “sm” ≈ 1rem */
:host([size="sm"]) .star {
width: var(--ag-space-4); /* 1rem */
height: var(--ag-space-4);
}
/* size="md" → Chakra UI “md” ≈ 1.25rem */
:host([size="md"]) .star {
width: var(--ag-space-5); /* 1.25rem */
height: var(--ag-space-5);
}
/* size="lg" → Chakra UI “lg” ≈ 1.5rem */
:host([size="lg"]) .star {
width: var(--ag-space-6); /* 1.5rem */
height: var(--ag-space-6);
}
.star svg path {
fill: var(--ag-neutral-300); /* Empty color */
}
.star.filled > svg > path:last-of-type,
.star.hover svg path {
fill: var(--ag-rating-filled, var(--ag-yellow-400));
}
:host([variant="primary"]) .star.filled > svg > path:last-of-type,
:host([variant="primary"]) .star.hover svg path {
fill: var(--ag-rating-filled-primary, var(--ag-primary));
}
:host([variant="success"]) .star.filled > svg > path:last-of-type,
:host([variant="success"]) .star.hover svg path {
fill: var(--ag-rating-filled-success, var(--ag-success));
}
:host([variant="warning"]) .star.filled > svg > path:last-of-type,
:host([variant="warning"]) .star.hover svg path {
fill: var(--ag-rating-filled-warning, var(--ag-warning));
}
:host([variant="danger"]) .star.filled > svg > path:last-of-type,
:host([variant="danger"]) .star.hover svg path {
fill: var(--ag-rating-filled-danger, var(--ag-danger));
}
:host([variant="secondary"]) .star.filled > svg > path:last-of-type,
:host([variant="secondary"]) .star.hover svg path {
fill: var(--ag-rating-filled-secondary, var(--ag-secondary));
}
:host([variant="monochrome"]) .star.filled > svg > path:last-of-type,
:host([variant="monochrome"]) .star.hover svg path {
fill: var(--ag-text-primary);
}
.star-button {
display: inline-block;
border: 0;
background: transparent;
padding: 0;
margin: 0;
line-height: 0;
cursor: inherit;
}
:host(:focus-visible) .rating {
box-shadow: 0 0 0 var(--ag-focus-offset) rgba(var(--ag-focus), 0.12);
}
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
`,
];
private renderLabel() {
if (!this.label || this.noLabel) return '';
const positionClasses: string[] = [];
if (isHorizontalLabel(this.labelPosition)) {
positionClasses.push('ag-form-control__label--horizontal');
positionClasses.push(`ag-form-control__label--${this.labelPosition}`);
} else if (this.labelPosition === 'bottom') {
positionClasses.push(`ag-form-control__label--${this.labelPosition}`);
}
return html`
${this.label}
`;
}
render() {
const displayValue = this.isHovering ? this.hoverValue : this.value;
const stars = Array.from({ length: this.max }, (_, i) => i + 1);
// Build aria-describedby
const describedBy = buildAriaDescribedBy({
helperId: this._helperId,
errorId: this._errorId,
hasHelper: !!this.helpText && !this.invalid,
hasError: !!this.invalid && !!this.errorMessage,
});
// Helper text rendering
const helperText = this.helpText && !this.invalid
? html`
${this.helpText}
`
: '';
// Error message rendering
const errorText = this.invalid && this.errorMessage
? html`
${this.errorMessage}
`
: '';
// Rating control
const ratingControl = html`
${stars.map((starIndex) => this.renderStar(starIndex, displayValue))}
${displayValue} of ${this.max}
`;
// Check if label should be in horizontal layout
const isHorizontal = isHorizontalLabel(this.labelPosition);
// Horizontal layout (start/end positions)
if (isHorizontal) {
return html`
${this.renderLabel()}
${ratingControl}
${helperText}
${errorText}
`;
}
// Bottom position layout
if (this.labelPosition === 'bottom') {
return html`
${ratingControl}
${helperText}
${errorText}
${this.renderLabel()}
`;
}
// Top position layout (default)
return html`
${this.renderLabel()}
${ratingControl}
${helperText}
${errorText}
`;
}
private renderStar(starIndex: number, displayValue: number) {
const full = displayValue >= starIndex;
const half = !full && displayValue >= starIndex - 0.5 && this.precision === 'half';
const filledClass = full || half ? 'filled' : '';
const hoverClass = this.isHovering && this.hoverValue >= starIndex ? 'hover' : '';
const clipId = `ag-rating-half-${this.uniqueId}-${starIndex}`;
return html`
this.handleClickStar(e, starIndex)}"
aria-label="${starIndex} star"
title="${starIndex} star"
>
${this.renderStarSvg(full, half, clipId)}
`;
}
private renderStarSvg(full: boolean, half: boolean, clipId: string) {
if (half) {
return svg`
`;
}
return svg`
`;
}
private roundToPrecision(value: number): number {
if (this.precision === 'half') {
return Math.round(value * 2) / 2;
}
return Math.round(value);
}
private getValueFromClientX(clientX: number): number {
const ratingElement = this.shadowRoot?.querySelector('.rating') as HTMLElement;
if (!ratingElement) return 0;
const rect = ratingElement.getBoundingClientRect();
const relativeX = Math.max(0, Math.min(clientX - rect.left, rect.width));
const proportion = rect.width > 0 ? relativeX / rect.width : 0;
const rawValue = proportion * this.max;
return this.roundToPrecision(rawValue);
}
private handleClickStar(e: MouseEvent, starIndex: number) {
e.stopPropagation();
if (this.readonly) return;
const oldValue = this.value;
let newValue = this.precision === 'half' ? starIndex : starIndex;
if (this.allowClear && newValue === oldValue) {
newValue = 0;
}
this.commitValue(newValue, oldValue);
}
private handlePointerEnter(_e: PointerEvent) {
// Placeholder for future enhancements
}
private handlePointerLeave(_e: PointerEvent) {
if (this.isPointerDown) return;
this.isHovering = false;
this.hoverValue = 0;
this.emitHoverEvent('end', this.hoverValue);
}
private handlePointerDown(e: PointerEvent) {
if (this.readonly) return;
this.isPointerDown = true;
this.setPointerCapture(e.pointerId);
const clientX = e.clientX;
const value = this.getValueFromClientX(clientX);
this.hoverValue = value;
this.isHovering = true;
this.emitHoverEvent('start', value);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
}
private handlePointerMoveHost(e: PointerEvent) {
if (!this.isPointerDown && !this.isHovering) return;
const clientX = e.clientX;
const value = this.getValueFromClientX(clientX);
this.hoverValue = value;
if (!this.isHovering) {
this.isHovering = true;
this.emitHoverEvent('start', value);
} else {
this.emitHoverEvent('move', value);
}
}
private handlePointerMove(e: PointerEvent) {
if (this.readonly) return;
const clientX = e.clientX;
const value = this.getValueFromClientX(clientX);
if (value !== this.hoverValue) {
this.hoverValue = value;
this.emitHoverEvent('move', value);
}
}
private handlePointerUp(e: PointerEvent) {
if (this.readonly) return;
const clientX = e.clientX;
const value = this.getValueFromClientX(clientX);
const oldValue = this.value;
let newValue = value;
if (this.allowClear && newValue === oldValue) {
newValue = 0;
}
this.commitValue(newValue, oldValue);
this.isPointerDown = false;
this.isHovering = false;
this.hoverValue = 0;
this.emitHoverEvent('end', value);
this.removeGlobalPointerListeners();
}
private removeGlobalPointerListeners() {
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
}
private handleKeyDown(e: KeyboardEvent) {
if (this.readonly) return;
const oldValue = this.value;
const step = this.precision === 'half' ? 0.5 : 1;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
this.value = Math.min(this.max, this.value + step);
this.commitValue(this.value, oldValue);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
this.value = Math.max(0, this.value - step);
this.commitValue(this.value, oldValue);
} else if (e.key === 'Home') {
e.preventDefault();
this.value = 0;
this.commitValue(this.value, oldValue);
} else if (e.key === 'End') {
e.preventDefault();
this.value = this.max;
this.commitValue(this.value, oldValue);
}
}
private commitValue(newValue: number, oldValue: number) {
const normalized = this.roundToPrecision(newValue);
this.value = normalized;
// FACE: sync form value and validity on user interaction
this._syncFormValue();
this._syncValidity();
const changeEvent = new CustomEvent('rating-change', {
detail: { oldValue, newValue: normalized },
bubbles: true,
composed: true
});
this.dispatchEvent(changeEvent);
if (this.onRatingChange) {
this.onRatingChange(changeEvent);
}
}
private emitHoverEvent(phase: 'start' | 'move' | 'end', value: number) {
const hoverEvent = new CustomEvent('rating-hover', {
detail: { phase, value },
bubbles: true,
composed: true
});
this.dispatchEvent(hoverEvent);
if (this.onRatingHover) {
this.onRatingHover(hoverEvent);
}
}
}