Angular Data GridStar Rating Cell Type - Step-by-Step Guide (Angular)

Overview

This guide shows how to create an interactive star rating cell using inline SVG stars with Angular's custom cell components. Perfect for product ratings, review scores, or any scenario where users need to provide a 1-5 star rating.

Difficulty: Beginner Time: ~15 minutes Libraries: None (pure HTML, SVG and JavaScript)

What You'll Build

A cell that:

  • Displays 5 SVG stars both when editing and viewing
  • Shows filled stars (gold) and unfilled stars (gray)
  • Uses Handsontable CSS tokens for theme-aware editor styling
  • Supports mouse hover for preview
  • Allows keyboard input (1-5 keys, arrow keys)
  • Provides immediate visual feedback
  • Highlights the current star (accent color) while editing
  • Works without any external libraries

Complete Example

    Step 1: Import Dependencies

    import { Component, ChangeDetectorRef, ChangeDetectionStrategy, inject } from "@angular/core";
    import { DomSanitizer } from "@angular/platform-browser";
    import {
      GridSettings,
      HotCellEditorAdvancedComponent,
      KeyboardShortcutConfig,
      HotCellRendererAdvancedComponent,
    } from "@handsontable/angular-wrapper";
    import { registerAllModules } from "handsontable/registry";
    
    registerAllModules();
    

    What we're importing:

    • HotCellRendererAdvancedComponent - Base class for custom renderers
    • HotCellEditorAdvancedComponent - Base class for custom editors with advanced features
    • KeyboardShortcutConfig - Type for keyboard shortcuts configuration
    • GridSettings - Type for Handsontable configuration
    • DomSanitizer - Required so we can render SVG via [innerHTML] (Angular strips SVG by default)
    • Angular core modules for component creation

    What we're NOT importing:

    • No date libraries
    • No UI component libraries
    • No external emoji libraries

    Step 2: Create the Renderer Component

    The renderer displays 5 SVG stars wrapped in a flex container using CSS classes for color control (same approach as the Star Rating recipe).

    const starSvg =
      '<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
    
    @Component({
      selector: "star-renderer",
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <div class="rating-cell">
          @for (star of stars; track $index) {
            <span class="rating-star" [class.active]="$index < value" [innerHTML]="starSvgMarkup"></span>
          }
        </div>`,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarRendererComponent extends HotCellRendererAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    }
    

    What's happening:

    • extends HotCellRendererAdvancedComponent<number> - Inherits base renderer functionality with typed value
    • value property - Automatically provided by the base class (1-5 rating)
    • .rating-cell - Flex container wrapping the stars (matches the editor layout)
    • .rating-star - Base class for each star (gray via CSS token --ht-background-secondary-color)
    • .active - Filled stars (gold #facc15)
    • [innerHTML]="starSvgMarkup" - Inline SVG with fill="currentColor" so CSS controls the star color
    • inject(DomSanitizer).bypassSecurityTrustHtml(starSvg) - Angular sanitizes [innerHTML] and strips SVG by default; marking the SVG as trusted allows it to render

    Why SVG instead of emoji?

    • Consistent rendering across all browsers and operating systems
    • Full control over color, size, and styling via CSS
    • Theme-aware when using Handsontable CSS tokens

    Step 3: Add CSS Styling

    Create a separate CSS file for the rating styles. This uses Handsontable CSS custom properties (tokens) so the editor automatically adapts to custom themes and dark mode.

    .rating-cell {
      display: flex;
      align-items: center;
      margin: 3px 0 0 -1px;
    }
    
    .rating-star {
      color: var(--ht-background-secondary-color, #e0e0e0);
      cursor: default;
      display: inline-flex;
      align-items: center;
    }
    
    .rating-star.active {
      color: #facc15;
    }
    
    .rating-editor {
      display: flex;
      align-items: center;
      width: 100%;
      height: 100%;
      box-sizing: border-box !important;
      border: none;
      border-radius: 0;
      box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px)
        var(--ht-cell-editor-border-color, #1a42e8),
        0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 0
        var(--ht-cell-editor-shadow-color, transparent);
      background-color: var(--ht-cell-editor-background-color, #ffffff);
      padding: var(--ht-cell-vertical-padding, 4px)
        var(--ht-cell-horizontal-padding, 8px);
      font-family: inherit;
      font-size: inherit;
      line-height: inherit;
      cursor: pointer;
    }
    
    .rating-editor .rating-star {
      cursor: pointer;
    }
    
    .rating-editor .rating-star.current {
      color: var(--ht-accent-color, #1a42e8);
    }
    

    Handsontable tokens used:

    • --ht-background-secondary-color - Inactive star color (adapts to theme)
    • --ht-accent-color - Current star highlight in editor
    • --ht-cell-editor-border-color / --ht-cell-editor-border-width - Editor border
    • --ht-cell-editor-background-color - Editor background
    • --ht-cell-vertical-padding / --ht-cell-horizontal-padding - Cell padding

    Step 4: Column Configuration (Optional Validator)

    In Angular, validators are typically configured at the column level in GridSettings. Here's how to ensure values are within the 1-5 star range:

    columns: [
      {
        data: "stars",
        width: 200,
        renderer: StarRendererComponent,
        editor: StarEditorComponent,
        // Optional: Add validator to ensure valid range
        validator: (value: number) => {
          const rating = parseInt(value?.toString() || "0");
          return rating >= 1 && rating <= 5;
        },
      },
    ];
    

    What's happening:

    • Angular validator uses CustomValidatorFn<T> - returns boolean directly
    • Convert value to integer (keyboard input may be strings)
    • Check if between 1 and 5 (star rating range)
    • Returns true for valid, false for invalid
    • Validator runs before saving to data model

    When to use:

    • Validating user input from keyboard shortcuts
    • Ensuring data integrity
    • Providing visual feedback for invalid values

    Step 5: Create the Editor Component

    The editor component extends HotCellEditorAdvancedComponent and provides interactive star selection using the same SVG and CSS classes as the renderer.

    @Component({
      standalone: false,
      template: `
        <div
          class="rating-editor"
          (mouseover)="onMouseOver($event)"
          (mousedown)="onMouseDown()"
        >
          @for (star of stars; track $index) {
            <span
              [attr.data-value]="$index + 1"
              class="rating-star"
              [class.active]="$index < getValue()"
              [class.current]="isCurrentStar($index)"
              [innerHTML]="starSvgMarkup"
            ></span>
          }
        </div>
      `,
      styleUrls: ["./example1.css"],
    })
    export class StarEditorComponent extends HotCellEditorAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    
      isCurrentStar(index: number): boolean {
        return (index + 1) === parseInt(this.getValue()?.toString() ?? "0", 10);
      }
      // ... event handlers and shortcuts
    }
    

    What's happening:

    • Container - class="rating-editor" uses the same theme-aware styling as the Star Rating recipe (blue border, padding, background via CSS tokens)
    • Stars - Same SVG as the renderer (via sanitized starSvgMarkup); .active for filled (gold), .current for the selected star (accent color)
    • isCurrentStar(index) - Template expressions can't call global parseInt, so we use a component method to compare the current value with the star index
    • getValue() - Method from base class returns current editor value
    • Event bindings - (mouseover) for hover preview, (mousedown) for selection

    Step 6: Editor - Mouse Event Handlers

    Add mouse interaction for hover preview and click selection. Use closest('.rating-star') so that when the user hovers over the SVG (or its <path>), we still find the parent span with data-value.

    export class StarEditorComponent extends HotCellEditorAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = starSvg;
    
      private readonly cdr = inject(ChangeDetectorRef);
    
      onMouseOver(event: MouseEvent): void {
        const star = (event.target as HTMLElement).closest(".rating-star") as HTMLElement | null;
        if (
          star?.dataset["value"] &&
          parseInt(this.getValue()?.toString() ?? "0", 10) !== parseInt(star.dataset["value"], 10)
        ) {
          this.setValue(parseInt(star.dataset["value"], 10));
        }
        this.cdr.detectChanges();
      }
    
      onMouseDown(): void {
        this.finishEdit.emit();
      }
    }
    

    What's happening:

    onMouseOver (Hover Preview):

    1. Get the hovered element; with inline SVG, the target may be the <svg> or <path>, not the span
    2. Use closest('.rating-star') to find the parent span with data-value
    3. If the hovered star's value differs from the current value, update it with setValue()
    4. Call cdr.detectChanges() so the view updates immediately

    onMouseDown (Click Selection):

    1. User clicks anywhere in the editor
    2. Emit finishEdit to close the editor and save the value

    Why closest()?

    • With inline SVGs, the event target is often the inner <path> or <svg> element
    • closest('.rating-star') walks up the DOM to the span that has data-value
    • Ensures hover and click work regardless of which part of the star is under the cursor

    Step 7: Editor - Keyboard Shortcuts

    Add keyboard support for rating selection using the shortcuts property from the base class.

    export class StarEditorComponent extends HotCellEditorAdvancedComponent<number> {
      readonly stars = Array(5);
    
      override shortcuts?: KeyboardShortcutConfig[] = [
        {
          keys: [["1"], ["2"], ["3"], ["4"], ["5"]],
          callback: (editor, _event) => {
            editor.setValue(_event.key);
          },
        },
        {
          keys: [["ArrowRight"]],
          callback: (editor, _event) => {
            if (parseInt(editor.value) < 5) {
              editor.setValue(parseInt(editor.value) + 1);
            }
          },
        },
        {
          keys: [["ArrowLeft"]],
          callback: (editor, _event) => {
            if (parseInt(editor.value) > 1) {
              editor.setValue(parseInt(editor.value) - 1);
            }
          },
        },
      ];
    
      private readonly cdr = inject(ChangeDetectorRef);
      // ... rest of the component
    }
    

    What's happening:

    Shortcuts Property:

    • override shortcuts - Overrides the base class property to define custom keyboard shortcuts
    • KeyboardShortcutConfig[] - Type-safe configuration for keyboard shortcuts
    • Handsontable automatically registers and handles these shortcuts

    Number Keys (1-5):

    • Press 1-5 to set rating directly
    • Fastest way to select a specific rating
    • Gets key value from keyboard event: _event.key
    • editor.setValue(_event.key) - Updates the value immediately

    Arrow Keys:

    • ArrowRight: Increase rating (max 5)
      • Check current value: parseInt(editor.value) < 5
      • Increment: editor.setValue(parseInt(editor.value) + 1)
    • ArrowLeft: Decrease rating (min 1)
      • Check current value: parseInt(editor.value) > 1
      • Decrement: editor.setValue(parseInt(editor.value) - 1)
    • Bounded within valid range
    • Smooth incremental adjustment

    Keyboard navigation benefits:

    • Fast selection without mouse
    • Accessible for keyboard-only users
    • Number keys for direct selection, arrows for adjustment
    • All shortcuts handled by Handsontable's shortcut manager

    Step 8: Complete Column Configuration

    Now combine the renderer and editor components in your column configuration:

    export class AppComponent {
      readonly data = [
        { id: 640329, itemName: "Lunar Core", stars: 4 },
        { id: 863104, itemName: "Zero Thrusters", stars: 5 },
        { id: 395603, itemName: "EVA Suits", stars: 3 },
      ];
    
      readonly gridSettings: GridSettings = {
        autoRowSize: true,
        rowHeaders: true,
        height: "auto",
        colHeaders: ["ID", "Item Name", "Stars Rating"],
        columns: [
          { data: "id", type: "numeric" },
          { data: "itemName", type: "text" },
          {
            data: "stars",
            width: 200,
            renderer: StarRendererComponent,
            editor: StarEditorComponent,
          },
        ],
      };
    }
    

    What's happening:

    • data: Array of objects with star ratings (1-5)
    • gridSettings: Typed with GridSettings for IntelliSense
    • columns configuration:
      • renderer: StarRendererComponent - Angular component for display
      • editor: StarEditorComponent - Angular component for editing
      • width: 200 - Column width for comfortable star display

    How it works:

    1. Handsontable detects that renderer/editor are Angular components
    2. Creates component instances dynamically
    3. Passes cell data to components via @Input properties
    4. Listens to component @Output events for editor lifecycle

    Step 9: Create the Angular Component

    Put it all together in your Angular component:

    @Component({
      selector: "app-star-rating",
      standalone: false,
      template: `
        <div>
          <hot-table [data]="data" [settings]="gridSettings"></hot-table>
        </div>
      `,
    })
    export class AppComponent {
      readonly data = [
        { id: 640329, itemName: "Lunar Core", stars: 4 },
        { id: 863104, itemName: "Zero Thrusters", stars: 5 },
        { id: 395603, itemName: "EVA Suits", stars: 3 },
      ];
    
      readonly gridSettings: GridSettings = {
        autoRowSize: true,
        rowHeaders: true,
        height: "auto",
        colHeaders: ["ID", "Item Name", "Stars Rating"],
        columns: [
          { data: "id", type: "numeric" },
          { data: "itemName", type: "text" },
          {
            data: "stars",
            width: 200,
            renderer: StarRendererComponent,
            editor: StarEditorComponent,
          },
        ],
      };
    }
    

    Step 10: Register in Angular Module

    Declare all components in your Angular module:

    import { NgModule, ApplicationConfig } from "@angular/core";
    import { BrowserModule } from "@angular/platform-browser";
    import { registerAllModules } from "handsontable/registry";
    import { HOT_GLOBAL_CONFIG, HotGlobalConfig, HotTableModule } from "@handsontable/angular-wrapper";
    import { CommonModule } from "@angular/common";
    import { NON_COMMERCIAL_LICENSE } from "@handsontable/angular-wrapper";
    import { AppComponent, StarEditorComponent, StarRendererComponent } from "./app.component";
    
    registerAllModules();
    
    export const appConfig: ApplicationConfig = {
      providers: [
        {
          provide: HOT_GLOBAL_CONFIG,
          useValue: {
            license: NON_COMMERCIAL_LICENSE,
          } as HotGlobalConfig,
        },
      ],
    };
    
    @NgModule({
      imports: [BrowserModule, HotTableModule, CommonModule],
      declarations: [AppComponent, StarEditorComponent, StarRendererComponent],
      providers: [...appConfig.providers],
      bootstrap: [AppComponent],
    })
    export class AppModule {}
    

    Important steps:

    1. Import HotTableModule - Provides <hot-table> directive
    2. Declare all components - Main component, renderer, and editor
    3. Register Handsontable modules - Call registerAllModules() before module creation
    4. Configure global settings - Use HOT_GLOBAL_CONFIG provider for theme and license

    Enhancements

    1. Show Numeric Value

    Display the numeric rating alongside stars:

    @Component({
      selector: "star-renderer-with-number",
      template: `
        <div style="display: flex; align-items: center; gap: 8px;">
          <div class="rating-cell">
            @for (star of stars; track $index) {
              <span class="rating-star" [class.active]="$index < value" [innerHTML]="starSvgMarkup"></span>
            }
          </div>
          <span style="color: #666; font-size: 14px;">({{ value }}/5)</span>
        </div>
      `,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarRendererWithNumberComponent extends HotCellRendererAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    }
    

    2. Color-Coded Stars

    Change star color based on rating value:

    @Component({
      selector: "star-renderer-colored",
      template: `
        <div class="rating-cell">
          @for (star of stars; track $index) {
            <span
              class="rating-star"
              [class.active]="$index < value"
              [style.color]="$index < value ? getColor() : undefined"
              [innerHTML]="starSvgMarkup"
            ></span>
          }
        </div>
      `,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarRendererColoredComponent extends HotCellRendererAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    
      getColor(): string {
        if (this.value >= 4) return "#ffd700"; // Gold
        if (this.value === 3) return "#ffa500"; // Orange
        return "#ff6347"; // Red
      }
    }
    

    3. Half-Star Ratings

    Support half-star ratings (0.5 increments):

    @Component({
      selector: "star-renderer-half",
      template: `
        <div class="rating-cell">
          @for (star of stars; track $index) {
          <span>
            @if ($index < fullStars) {
            <span class="rating-star active" [innerHTML]="starSvgMarkup"></span>
            } @if ($index === fullStars && hasHalf) {
            <span class="rating-star" style="opacity: 0.7" [innerHTML]="starSvgMarkup"></span>
            } @if ($index >= fullStars && !($index === fullStars && hasHalf)) {
            <span class="rating-star" [innerHTML]="starSvgMarkup"></span>
            }
          </span>
          }
        </div>
      `,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarRendererHalfComponent extends HotCellRendererAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    
      get fullStars(): number {
        return Math.floor(this.value);
      }
    
      get hasHalf(): boolean {
        return this.value % 1 !== 0;
      }
    }
    
    // Update validator in column configuration
    columns: [
      {
        data: "stars",
        renderer: StarRendererHalfComponent,
        validator: (value: number) => {
          const rating = parseFloat(value?.toString() || "0");
          return rating >= 0.5 && rating <= 5 && rating % 0.5 === 0;
        },
      },
    ];
    

    4. Custom Star Count

    Configurable number of stars per column using rendererProps:

    @Component({
      selector: "star-renderer-custom",
      template: `
        <div class="rating-cell">
          @for (star of getStarsArray(); track $index) {
          <span class="rating-star" [class.active]="$index < value" [innerHTML]="starSvgMarkup"></span>
          }
        </div>
      `,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarRendererCustomComponent extends HotCellRendererAdvancedComponent<number, { maxStars?: number }> {
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    
      getStarsArray(): unknown[] {
        const maxStars = this.getProps().maxStars ?? 5;
        return Array(maxStars);
      }
    }
    
    // Usage in column configuration
    columns: [
      {
        data: "stars",
        renderer: StarRendererCustomComponent,
        // Pass custom properties via cellProperties
        rendererProps: { maxStars: 10 },
      },
    ];
    

    5. Text Labels

    Add text labels like "Excellent", "Good", etc.:

    @Component({
      selector: "star-renderer-labels",
      template: `
        <div style="display: flex; flex-direction: column; gap: 4px;">
          <div class="rating-cell">
            @for (star of stars; track $index) {
              <span class="rating-star" [class.active]="$index < value" [innerHTML]="starSvgMarkup"></span>
            }
          </div>
          <div style="font-size: 12px; color: #666;">
            {{ getLabel() }}
          </div>
        </div>
      `,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarRendererLabelsComponent extends HotCellRendererAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
      readonly labels = ["", "Poor", "Fair", "Good", "Very Good", "Excellent"];
    
      getLabel(): string {
        return this.labels[this.value] ?? "";
      }
    }
    

    Accessibility

    Add ARIA attributes for screen readers:

    @Component({
      selector: "star-editor-accessible",
      template: `
        <div
          class="rating-editor"
          role="radiogroup"
          aria-label="Star rating from 1 to 5"
          (mouseover)="onMouseOver($event)"
          (mousedown)="onMouseDown()"
        >
          @for (star of stars; track $index) {
          <span
            [attr.data-value]="$index + 1"
            class="rating-star"
            [class.active]="$index < getValue()"
            [class.current]="isCurrentStar($index)"
            [innerHTML]="starSvgMarkup"
            role="radio"
            [attr.aria-checked]="$index < getValue()"
            [attr.aria-label]="$index + 1 + ' star' + ($index > 0 ? 's' : '')"
            tabindex="0"
          ></span>
          }
        </div>
      `,
      styleUrls: ["./example1.css"],
      standalone: false,
    })
    export class StarEditorAccessibleComponent extends HotCellEditorAdvancedComponent<number> {
      readonly stars = Array(5);
      readonly starSvgMarkup = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
    
      isCurrentStar(index: number): boolean {
        return (index + 1) === parseInt(this.getValue()?.toString() ?? "0", 10);
      }
    
      private readonly cdr = inject(ChangeDetectorRef);
    
      // ... rest of implementation (onMouseOver with closest('.rating-star'), onMouseDown)
    }
    

    Keyboard navigation:

    • Number keys (1-5): Direct rating selection
    • Arrow Right: Increase rating (max 5)
    • Arrow Left: Decrease rating (min 1)
    • Enter: Confirm selection and finish editing
    • Escape: Cancel editing
    • Tab: Navigate to editor

    ARIA attributes:

    • role="radiogroup": Identifies the star group
    • role="radio": Identifies each star as a radio option
    • aria-label: Describes each star (e.g., "1 star", "2 stars")
    • aria-checked: Indicates selected stars
    • tabindex: Controls keyboard focus order

    Congratulations! You've created a theme-aware SVG star rating editor with hover preview and keyboard support using Angular components, perfect for intuitive 1-5 star ratings in your data grid!