• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-webcomponents / 23836148231

01 Apr 2026 06:57AM UTC coverage: 98.363% (-0.004%) from 98.367%
23836148231

push

github

web-flow
refactor(rating): Improved code quality and architecture (#2171)

* refactor(rating): Improved code quality and architecture

- Replaced `guard` directive with propery `updated`
lifecycle method to handle the projected symbols update.
- Use the internal slot controller for managing the projected symbols.
- Replaced the generator-based rendering with a more efficient
`repeat` + `range` DOM diffing when `max` changes.
- The component now follows the project code style and architecture guidelines.
- Improved JSDoc description, added slot entries and annotated
example usage.

5578 of 5875 branches covered (94.94%)

Branch coverage included in aggregate %.

202 of 204 new or added lines in 1 file covered. (99.02%)

4 existing lines in 2 files now uncovered.

39183 of 39631 relevant lines covered (98.87%)

1506.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.86
/src/components/rating/rating.ts
1
import { html, LitElement, nothing } from 'lit';
8✔
2
import { property, query, state } from 'lit/decorators.js';
8✔
3
import { range } from 'lit/directives/range.js';
8✔
4
import { repeat } from 'lit/directives/repeat.js';
8✔
5
import { styleMap } from 'lit/directives/style-map.js';
8✔
6
import { addThemingController } from '../../theming/theming-controller.js';
8✔
7
import {
8✔
8
  addKeybindings,
8✔
9
  arrowDown,
8✔
10
  arrowLeft,
8✔
11
  arrowRight,
8✔
12
  arrowUp,
8✔
13
  endKey,
8✔
14
  homeKey,
8✔
15
} from '../common/controllers/key-bindings.js';
8✔
16
import {
8✔
17
  addSlotController,
8✔
18
  type InferSlotNames,
8✔
19
  type SlotChangeCallbackParameters,
8✔
20
  setSlots,
8✔
21
} from '../common/controllers/slot.js';
8✔
22
import { registerComponent } from '../common/definitions/register.js';
8✔
23
import type { Constructor } from '../common/mixins/constructor.js';
8✔
24
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
8✔
25
import { FormAssociatedMixin } from '../common/mixins/forms/associated.js';
8✔
26
import { FormValueNumberTransformers } from '../common/mixins/forms/form-transformers.js';
8✔
27
import { createFormValueState } from '../common/mixins/forms/form-value.js';
8✔
28
import {
8✔
29
  asNumber,
8✔
30
  bindIf,
8✔
31
  clamp,
8✔
32
  formatString,
8✔
33
  isLTR,
8✔
34
  numberOfDecimals,
8✔
35
  roundPrecise,
8✔
36
} from '../common/util.js';
8✔
37
import IgcIconComponent from '../icon/icon.js';
8✔
38
import IgcRatingSymbolComponent from './rating-symbol.js';
8✔
39
import { styles } from './themes/rating.base.css.js';
8✔
40
import { styles as shared } from './themes/shared/rating.common.css.js';
8✔
41
import { all } from './themes/themes.js';
8✔
42

8✔
43
export interface IgcRatingComponentEventMap {
8✔
44
  igcChange: CustomEvent<number>;
8✔
45
  igcHover: CustomEvent<number>;
8✔
46
}
8✔
47

8✔
48
const Slots = setSlots('symbol', 'value-label');
8✔
49

8✔
50
/**
8✔
51
 * A rating component that allows users to view and provide ratings using customizable symbols.
8✔
52
 * It supports fractional values, hover previews, keyboard navigation, single-selection mode,
8✔
53
 * and integrates with forms as a number input.
8✔
54
 *
8✔
55
 * @example
8✔
56
 * ```html
8✔
57
 * <!-- Basic rating -->
8✔
58
 * <igc-rating value="3" max="5" label="Rate this product"></igc-rating>
8✔
59
 * ```
8✔
60
 *
8✔
61
 * @example
8✔
62
 * ```html
8✔
63
 * <!-- Half-star rating with hover preview -->
8✔
64
 * <igc-rating step="0.5" hover-preview value-format="{0} out of {1} stars"></igc-rating>
8✔
65
 * ```
8✔
66
 *
8✔
67
 * @example
8✔
68
 * ```html
8✔
69
 * <!-- Custom symbols via projected rating symbols -->
8✔
70
 * <igc-rating>
8✔
71
 *   <igc-rating-symbol>
8✔
72
 *     <igc-icon name="heart_filled" collection="default"></igc-icon>
8✔
73
 *     <igc-icon name="heart_outlined" collection="default" slot="empty"></igc-icon>
8✔
74
 *   </igc-rating-symbol>
8✔
75
 *   <igc-rating-symbol>
8✔
76
 *     <igc-icon name="heart_filled" collection="default"></igc-icon>
8✔
77
 *     <igc-icon name="heart_outlined" collection="default" slot="empty"></igc-icon>
8✔
78
 *   </igc-rating-symbol>
8✔
79
 * </igc-rating>
8✔
80
 * ```
8✔
81
 *
8✔
82
 * @element igc-rating
8✔
83
 *
8✔
84
 * @slot symbol - Slot for projecting custom `igc-rating-symbol` elements. When used, the number of symbols determines the `max` value.
8✔
85
 * @slot value-label - Slot for custom content displayed alongside the rating value.
8✔
86
 *
8✔
87
 * @fires igcChange - Emitted when the value of the control changes.
8✔
88
 * @fires igcHover - Emitted when hover is enabled and the user mouses over a symbol of the rating.
8✔
89
 *
8✔
90
 * @csspart base - The main wrapper which holds all of the rating elements.
8✔
91
 * @csspart label - The label part.
8✔
92
 * @csspart value-label - The value label part.
8✔
93
 * @csspart symbols - A wrapper for all rating symbols.
8✔
94
 * @csspart symbol - The part of the encapsulated default symbol.
8✔
95
 * @csspart full - The part of the encapsulated full symbols.
8✔
96
 * @csspart empty - The part of the encapsulated empty symbols.
8✔
97
 *
8✔
98
 * @cssproperty --symbol-size - The size of the symbols.
8✔
99
 * @cssproperty --symbol-full-color - The color of the filled symbol.
8✔
100
 * @cssproperty --symbol-empty-color - The color of the empty symbol.
8✔
101
 * @cssproperty --symbol-full-filter - The filter(s) used for the filled symbol.
8✔
102
 * @cssproperty --symbol-empty-filter - The filter(s) used for the empty symbol.
8✔
103
 */
8✔
104
export default class IgcRatingComponent extends FormAssociatedMixin(
8✔
105
  EventEmitterMixin<IgcRatingComponentEventMap, Constructor<LitElement>>(
8✔
106
    LitElement
8✔
107
  )
8✔
108
) {
8✔
109
  public static readonly tagName = 'igc-rating';
8✔
110
  public static styles = [styles, shared];
8✔
111

8✔
112
  /* blazorSuppress */
8✔
113
  public static register(): void {
8✔
114
    registerComponent(
1✔
115
      IgcRatingComponent,
1✔
116
      IgcIconComponent,
1✔
117
      IgcRatingSymbolComponent
1✔
118
    );
1✔
119
  }
1✔
120

8✔
121
  //#region Internal state and properties
8✔
122

8✔
123
  protected readonly _slots = addSlotController(this, {
8✔
124
    slots: Slots,
8✔
125
    onChange: this._handleSlotChange,
8✔
126
  });
8✔
127

8✔
128
  protected override readonly _formValue = createFormValueState(this, {
8✔
129
    initialValue: 0,
8✔
130
    transformers: FormValueNumberTransformers,
8✔
131
  });
8✔
132

8✔
133
  private _max = 5;
8✔
134
  private _step = 1;
8✔
135
  private _single = false;
8✔
136
  private _symbols: IgcRatingSymbolComponent[] = [];
8✔
137

8✔
138
  @query('[part="symbols"]', true)
8✔
139
  private _container?: HTMLElement;
8✔
140

8✔
141
  @state()
8✔
142
  private _hoverValue = -1;
8✔
143

8✔
144
  @state()
8✔
145
  private _hoverState = false;
8✔
146

8✔
147
  private get _isInteractive(): boolean {
8✔
148
    return !(this.readOnly || this.disabled);
132✔
149
  }
132✔
150

8✔
151
  private get _hasProjectedSymbols(): boolean {
8✔
152
    return this._symbols.length > 0;
214✔
153
  }
214✔
154

8✔
155
  private get _valueText(): string {
8✔
156
    // Skip IEEE 754 representation for screen readers
98✔
157
    const value = this._round(this.value);
98✔
158
    return this.valueFormat
98✔
159
      ? formatString(this.valueFormat, value, this.max)
3✔
160
      : `${value} of ${this.max}`;
95✔
161
  }
98✔
162

8✔
163
  //#endregion
8✔
164

8✔
165
  //#region Public attributes and properties
8✔
166

8✔
167
  /**
8✔
168
   * The maximum value for the rating.
8✔
169
   *
8✔
170
   * If there are projected symbols, the maximum value will be resolved
8✔
171
   * based on the number of symbols.
8✔
172
   * @attr max
8✔
173
   * @default 5
8✔
174
   */
8✔
175
  @property({ type: Number })
8✔
176
  public set max(value: number) {
8✔
177
    this._max = this._hasProjectedSymbols
11✔
178
      ? this._symbols.length
4✔
179
      : Math.max(0, value);
7✔
180

11✔
181
    if (this._max < this.value) {
11✔
182
      this.value = this._max;
1✔
183
    }
1✔
184
  }
11✔
185

8✔
186
  public get max(): number {
8✔
187
    return this._max;
491✔
188
  }
491✔
189

8✔
190
  /**
8✔
191
   * The minimum value change allowed.
8✔
192
   *
8✔
193
   * Valid values are in the interval between 0 and 1 inclusive.
8✔
194
   * @attr step
8✔
195
   * @default 1
8✔
196
   */
8✔
197
  @property({ type: Number })
8✔
198
  public set step(value: number) {
8✔
199
    this._step = this.single ? 1 : clamp(value, 0.001, 1);
9✔
200
  }
9✔
201

8✔
202
  public get step(): number {
8✔
203
    return this._step;
417✔
204
  }
417✔
205

8✔
206
  /**
8✔
207
   * The label of the control.
8✔
208
   * @attr label
8✔
209
   */
8✔
210
  @property()
8✔
211
  public label?: string;
8✔
212

8✔
213
  /**
8✔
214
   * A format string which sets aria-valuetext. Instances of '{0}' will be replaced
8✔
215
   * with the current value of the control and instances of '{1}' with the maximum value for the control.
8✔
216
   *
8✔
217
   * Important for screen-readers and useful for localization.
8✔
218
   * @attr value-format
8✔
219
   */
8✔
220
  @property({ attribute: 'value-format' })
8✔
221
  public valueFormat?: string;
8✔
222

8✔
223
  /* @tsTwoWayProperty(true, "igcChange", "detail", false) */
8✔
224
  /**
8✔
225
   * The current value of the component
8✔
226
   * @attr value
8✔
227
   * @default 0
8✔
228
   */
8✔
229
  @property({ type: Number })
8✔
230
  public set value(number: number) {
8✔
231
    const value = this.hasUpdated
52✔
232
      ? clamp(asNumber(number), 0, this.max)
39✔
233
      : Math.max(asNumber(number), 0);
13✔
234
    this._formValue.setValueAndFormState(value);
52✔
235
  }
52✔
236

8✔
237
  public get value(): number {
8✔
238
    return this._formValue.value;
1,065✔
239
  }
1,065✔
240

8✔
241
  /**
8✔
242
   * Sets hover preview behavior for the component
8✔
243
   * @attr hover-preview
8✔
244
   */
8✔
245
  @property({ type: Boolean, reflect: true, attribute: 'hover-preview' })
8✔
246
  public hoverPreview = false;
8✔
247

8✔
248
  /**
8✔
249
   * Makes the control a readonly field.
8✔
250
   * @attr readonly
8✔
251
   */
8✔
252
  @property({ type: Boolean, reflect: true, attribute: 'readonly' })
8✔
253
  public readOnly = false;
8✔
254

8✔
255
  /**
8✔
256
   * Toggles single selection visual mode.
8✔
257
   * @attr single
8✔
258
   * @default false
8✔
259
   */
8✔
260
  @property({ type: Boolean, reflect: true })
8✔
261
  public set single(value: boolean) {
8✔
262
    this._single = Boolean(value);
1✔
263

1✔
264
    if (this._single) {
1✔
265
      this.step = 1;
1✔
266
      this.value = Math.ceil(this.value);
1✔
267
    }
1✔
268
  }
1✔
269

8✔
270
  public get single(): boolean {
8✔
271
    return this._single;
664✔
272
  }
664✔
273

8✔
274
  /**
8✔
275
   * Whether to reset the rating when the user selects the same value.
8✔
276
   * @attr allow-reset
8✔
277
   * @default false
8✔
278
   */
8✔
279
  @property({ type: Boolean, reflect: true, attribute: 'allow-reset' })
8✔
280
  public allowReset = false;
8✔
281

8✔
282
  //#endregion
8✔
283

8✔
284
  //#region Lit lifecycle hooks
8✔
285

8✔
286
  constructor() {
8✔
287
    super();
48✔
288

48✔
289
    addThemingController(this, all);
48✔
290

48✔
291
    addKeybindings(this, {
48✔
292
      skip: () => !this._isInteractive,
48✔
293
      bindingDefaults: { repeat: true },
48✔
294
    })
48✔
295
      .set(arrowUp, () => this._emitValueUpdate(this.value + this.step))
48✔
296
      .set(arrowRight, () =>
48✔
297
        this._emitValueUpdate(
5✔
298
          isLTR(this) ? this.value + this.step : this.value - this.step
5✔
299
        )
5✔
300
      )
48✔
301
      .set(arrowDown, () => this._emitValueUpdate(this.value - this.step))
48✔
302
      .set(arrowLeft, () =>
48✔
303
        this._emitValueUpdate(
4✔
304
          isLTR(this) ? this.value - this.step : this.value + this.step
4✔
305
        )
4✔
306
      )
48✔
307
      .set(homeKey, () => this._emitValueUpdate(this.step))
48✔
308
      .set(endKey, () => this._emitValueUpdate(this.max));
48✔
309
  }
48✔
310

8✔
311
  protected override firstUpdated(): void {
8✔
312
    this._formValue.setValueAndFormState(clamp(this.value, 0, this.max));
48✔
313
    this._pristine = true;
48✔
314
  }
48✔
315

8✔
316
  protected override updated(): void {
8✔
317
    if (this._hasProjectedSymbols) {
98✔
318
      this._updateProjectedSymbols();
3✔
319
    }
3✔
320
  }
98✔
321

8✔
322
  //#endregion
8✔
323

8✔
324
  //#region Event handlers
8✔
325

8✔
326
  private _handleClick({ clientX }: PointerEvent): void {
8✔
327
    const value = this._calcNewValue(clientX);
6✔
328
    const sameValue = this.value === value;
6✔
329

6✔
330
    if (this.allowReset && sameValue) {
6✔
331
      this._emitValueUpdate(0);
1✔
332
    } else if (!sameValue) {
6✔
333
      this._emitValueUpdate(value);
4✔
334
    }
4✔
335
  }
6✔
336

8✔
337
  private _handlePointerMove({ clientX }: PointerEvent): void {
8✔
338
    const value = this._calcNewValue(clientX);
1✔
339

1✔
340
    if (this._hoverValue !== value) {
1✔
341
      // Since pointermove spams a lot, only emit on a value change
1✔
342
      this._hoverValue = value;
1✔
343
      this.emitEvent('igcHover', { detail: this._hoverValue });
1✔
344
    }
1✔
345
  }
1✔
346

8✔
347
  private _handleSlotChange({
8✔
348
    slot,
7✔
349
  }: SlotChangeCallbackParameters<InferSlotNames<typeof Slots>>): void {
7✔
350
    if (slot === 'symbol') {
7✔
351
      this._symbols = this._slots.getAssignedElements('symbol', {
7✔
352
        selector: IgcRatingSymbolComponent.tagName,
7✔
353
      });
7✔
354

7✔
355
      if (this._hasProjectedSymbols) {
7✔
356
        this.max = this._symbols.length;
3✔
357
      }
3✔
358
    }
7✔
359
  }
7✔
360

8✔
361
  private _handleHoverEnabled(): void {
8✔
NEW
362
    this._hoverState = true;
×
UNCOV
363
  }
×
364

8✔
365
  private _handleHoverDisabled(): void {
8✔
NEW
366
    this._hoverState = false;
×
UNCOV
367
  }
×
368

8✔
369
  //#endregion
8✔
370

8✔
371
  //#region Private methods
8✔
372

8✔
373
  private _emitValueUpdate(next: number): void {
8✔
374
    this._setTouchedState();
20✔
375

20✔
376
    const clamped = clamp(next, 0, this.max);
20✔
377
    if (clamped !== this.value) {
20✔
378
      this.value = clamped;
15✔
379
      this.emitEvent('igcChange', { detail: this.value });
15✔
380
    }
15✔
381
  }
20✔
382

8✔
383
  private _calcNewValue(x: number): number {
8✔
384
    const { width, left, right } =
7✔
385
      this._container?.getBoundingClientRect() ?? new DOMRect(1, 1, 1, 1);
7!
386
    const percent = isLTR(this) ? (x - left) / width : (right - x) / width;
7!
387
    const value = this._round(this.max * percent);
7✔
388

7✔
389
    return clamp(value, this.step, this.max);
7✔
390
  }
7✔
391

8✔
392
  private _round(value: number): number {
8✔
393
    return roundPrecise(
108✔
394
      Math.ceil(value / this.step) * this.step,
108✔
395
      numberOfDecimals(this.step)
108✔
396
    );
108✔
397
  }
108✔
398

8✔
399
  private _updateProjectedSymbols(): void {
8✔
400
    const ltr = isLTR(this);
3✔
401
    const partFull = '[part="symbol full"]';
3✔
402
    const partEmpty = '[part="symbol empty"]';
3✔
403

3✔
404
    for (const [i, symbol] of this._symbols.entries()) {
3✔
405
      const full = symbol.renderRoot.querySelector<HTMLElement>(partFull);
9✔
406
      const empty = symbol.renderRoot.querySelector<HTMLElement>(partEmpty);
9✔
407
      const { forward, backward } = this._clipSymbol(i, ltr);
9✔
408

9✔
409
      if (full) {
9✔
410
        full.style.clipPath = forward;
9✔
411
      }
9✔
412

9✔
413
      if (empty) {
9✔
414
        empty.style.clipPath = backward;
9✔
415
      }
9✔
416
    }
9✔
417
  }
3✔
418

8✔
419
  private _clipSymbol(index: number, isLTR = true) {
8✔
420
    const value = this._hoverState ? this._hoverValue : this.value;
556!
421
    const progress = index + 1 - value;
556✔
422
    const exclusive = progress === 0 || value === index + 1 ? 0 : 1;
556✔
423
    const selection = this.single ? exclusive : progress;
556✔
424
    const activate = (p: number) => clamp(p * 100, 0, 100);
556✔
425

556✔
426
    const forward = `inset(0 ${activate(
556✔
427
      isLTR ? selection : 1 - selection
556✔
428
    )}% 0 0)`;
556✔
429
    const backward = `inset(0 0 0 ${activate(
556✔
430
      isLTR ? 1 - selection : selection
556✔
431
    )}%)`;
556✔
432

556✔
433
    return {
556✔
434
      backward: isLTR ? backward : forward,
556✔
435
      forward: isLTR ? forward : backward,
556✔
436
    };
556✔
437
  }
556✔
438

8✔
439
  //#endregion
8✔
440

8✔
441
  //#region Public methods
8✔
442

8✔
443
  /**
8✔
444
   * Increments the value of the control by `n` steps multiplied by the
8✔
445
   * step factor.
8✔
446
   */
8✔
447
  public stepUp(n = 1): void {
8✔
448
    this.value += this._round(n * this.step);
2✔
449
  }
2✔
450

8✔
451
  /**
8✔
452
   * Decrements the value of the control by `n` steps multiplied by
8✔
453
   * the step factor.
8✔
454
   */
8✔
455
  public stepDown(n = 1): void {
8✔
456
    this.value -= this._round(n * this.step);
1✔
457
  }
1✔
458

8✔
459
  //#endregion
8✔
460

8✔
461
  private _renderSymbols() {
8✔
462
    const ltr = isLTR(this);
95✔
463

95✔
464
    return html`
95✔
465
      ${repeat(
95✔
466
        range(this.max),
95✔
467
        (i) => i,
95✔
468
        (i) => {
95✔
469
          const { forward, backward } = this._clipSymbol(i, ltr);
547✔
470
          return html`
547✔
471
            <igc-rating-symbol exportparts="symbol, full, empty">
547✔
472
              <igc-icon
547✔
473
                collection="default"
547✔
474
                name="star_filled"
547✔
475
                style=${styleMap({ clipPath: forward })}
547✔
476
              ></igc-icon>
547✔
477
              <igc-icon
547✔
478
                collection="default"
547✔
479
                name="star_outlined"
547✔
480
                style=${styleMap({ clipPath: backward })}
547✔
481
                slot="empty"
547✔
482
              ></igc-icon>
547✔
483
            </igc-rating-symbol>
547✔
484
          `;
547✔
485
        }
547✔
486
      )}
95✔
487
    `;
95✔
488
  }
95✔
489

8✔
490
  protected override render() {
8✔
491
    const hoverActive = this.hoverPreview && this._isInteractive;
98✔
492
    const valueLabelHidden = !this._slots.hasAssignedNodes('value-label', true);
98✔
493

98✔
494
    return html`
98✔
495
      <label part="label" id="rating-label" ?hidden=${!this.label}
98✔
496
        >${this.label}</label
98✔
497
      >
98✔
498
      <div
98✔
499
        part="base"
98✔
500
        role="slider"
98✔
501
        tabindex=${this.disabled ? -1 : 0}
98✔
502
        aria-labelledby="rating-label"
98✔
503
        aria-valuemin="0"
98✔
504
        aria-valuenow=${this.value}
98✔
505
        aria-valuemax=${this.max}
98✔
506
        aria-valuetext=${this._valueText}
98✔
507
      >
98✔
508
        <div
98✔
509
          aria-hidden="true"
98✔
510
          part="symbols"
98✔
511
          @click=${bindIf(this._isInteractive, this._handleClick)}
98✔
512
          @pointerenter=${bindIf(hoverActive, this._handleHoverEnabled)}
98✔
513
          @pointerleave=${bindIf(hoverActive, this._handleHoverDisabled)}
98✔
514
          @pointercancel=${bindIf(hoverActive, this._handleHoverDisabled)}
98✔
515
          @pointermove=${bindIf(hoverActive, this._handlePointerMove)}
98✔
516
        >
98✔
517
          <slot name="symbol">
98✔
518
            ${this._hasProjectedSymbols ? nothing : this._renderSymbols()}
98✔
519
          </slot>
98✔
520
        </div>
98✔
521
        <label part="value-label" ?hidden=${valueLabelHidden}>
98✔
522
          <slot name="value-label"></slot>
98✔
523
        </label>
98✔
524
      </div>
98✔
525
    `;
98✔
526
  }
98✔
527
}
8✔
528

8✔
529
declare global {
8✔
530
  interface HTMLElementTagNameMap {
8✔
531
    'igc-rating': IgcRatingComponent;
8✔
532
  }
8✔
533
}
8✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc