diff --git a/src/tedi/components/form/index.ts b/src/tedi/components/form/index.ts
index 557690f65..fca0ea079 100644
--- a/src/tedi/components/form/index.ts
+++ b/src/tedi/components/form/index.ts
@@ -11,6 +11,7 @@ export * from "./date-picker/date-picker.component";
export * from "./feedback-text/feedback-text.component";
export * from "./label-row";
export * from "./number-field/number-field.component";
+export * from "./rating";
export * from "./search";
export * from "./select";
export * from "./slider/slider.component";
diff --git a/src/tedi/components/form/rating/index.ts b/src/tedi/components/form/rating/index.ts
new file mode 100644
index 000000000..d6ffafe2f
--- /dev/null
+++ b/src/tedi/components/form/rating/index.ts
@@ -0,0 +1 @@
+export * from "./rating.component";
diff --git a/src/tedi/components/form/rating/rating.component.html b/src/tedi/components/form/rating/rating.component.html
new file mode 100644
index 000000000..ca5692c5a
--- /dev/null
+++ b/src/tedi/components/form/rating/rating.component.html
@@ -0,0 +1,114 @@
+@if (readOnly()) {
+
+ @if (type() === "star" && readOnlyVariant() === "scale") {
+
+ @for (fill of partialFills(); track $index) {
+
+
+ @if (fill > 0) {
+
+
+
+ }
+
+ }
+
+ } @else if (type() === "star") {
+
+ } @else if (type() === "icon") {
+
+
+
+ }
+ {{ summary() }}
+
+} @else {
+
+ @for (item of items(); track item.position) {
+
+
+
+ @if (type() === "star") {
+
+
+
+ } @else {
+
+ @if (type() === "number") {
+ {{ item.position }}
+ } @else {
+
+ }
+
+ }
+
+ @if (item.caption) {
+ {{ item.caption }}
+ }
+
+ }
+
+
+ @if (starCaption() !== undefined) {
+ {{ starCaption() }}
+ }
+}
diff --git a/src/tedi/components/form/rating/rating.component.scss b/src/tedi/components/form/rating/rating.component.scss
new file mode 100644
index 000000000..7e4778ae5
--- /dev/null
+++ b/src/tedi/components/form/rating/rating.component.scss
@@ -0,0 +1,234 @@
+@use "@tedi-design-system/core/bootstrap-utility/breakpoints";
+
+.tedi-rating {
+ display: flex;
+ flex-direction: column;
+ gap: var(--layout-grid-gutters-04);
+ align-items: flex-start;
+ width: fit-content;
+}
+
+.tedi-rating__items {
+ display: flex;
+ gap: var(--feedback-star-inner-spacing);
+ align-items: flex-start;
+}
+
+.tedi-rating--number .tedi-rating__items,
+.tedi-rating--icon .tedi-rating__items {
+ flex-wrap: wrap;
+ gap: var(--layout-grid-gutters-08);
+}
+
+.tedi-rating__item {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ gap: var(--layout-grid-gutters-04);
+ align-items: center;
+ cursor: pointer;
+}
+
+.tedi-rating--disabled .tedi-rating__item {
+ cursor: not-allowed;
+}
+
+.tedi-rating--vertical .tedi-rating__items {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.tedi-rating--vertical .tedi-rating__item {
+ flex-direction: row;
+ gap: var(--layout-grid-gutters-08);
+ justify-content: flex-start;
+}
+
+.tedi-rating--vertical .tedi-rating__caption {
+ text-align: left;
+}
+
+.tedi-rating__input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ cursor: inherit;
+ opacity: 0;
+}
+
+.tedi-rating__visual {
+ position: relative;
+ display: inline-flex;
+}
+
+.tedi-rating .tedi-rating__star {
+ position: relative; // paint above the absolutely positioned backdrop
+ line-height: 1;
+ color: var(--general-text-brand);
+ text-align: center;
+}
+
+.tedi-rating--star .tedi-rating__visual {
+ padding: var(--feedback-star-inner-spacing);
+}
+
+// Filled glyph stacked under the outlined one; transparent unless the star is hovered while unselected.
+.tedi-rating .tedi-rating__star-backdrop {
+ position: absolute;
+ inset: var(--feedback-star-inner-spacing);
+ color: transparent;
+}
+
+.tedi-rating__circle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--button-sm-height);
+ height: var(--button-sm-height);
+ font-size: var(--body-small-regular-size);
+ color: var(--general-text-brand);
+ background-color: var(--general-surface-primary);
+ border: var(--tedi-borders-01) solid var(--general-border-secondary);
+ border-radius: 50%;
+}
+
+.tedi-rating__visual--selected .tedi-rating__circle,
+.tedi-rating__circle--filled {
+ color: var(--general-text-white);
+ background-color: var(--general-surface-brand-primary);
+ border-color: var(--general-surface-brand-primary);
+}
+
+.tedi-rating:not(.tedi-rating--disabled) {
+
+ .tedi-rating__visual--hover,
+ .tedi-rating__item:hover>.tedi-rating__visual {
+ .tedi-rating__star {
+ color: var(--general-icon-brand-dark);
+ }
+
+ .tedi-rating__star-backdrop {
+ color: var(--general-surface-brand-quaternary);
+ }
+
+ .tedi-rating__circle {
+ color: var(--general-icon-brand-dark);
+ background-color: var(--general-surface-brand-quaternary);
+ border-color: var(--general-icon-brand-dark);
+ }
+
+ &.tedi-rating__visual--selected .tedi-rating__circle {
+ color: var(--general-text-white);
+ background-color: var(--general-icon-brand-dark);
+ border-color: var(--general-icon-brand-dark);
+ }
+ }
+}
+
+.tedi-rating__input:focus-visible+.tedi-rating__visual::after {
+ position: absolute;
+ inset: -3px;
+ pointer-events: none;
+ content: "";
+ border-radius: 50%;
+ box-shadow:
+ inset 0 0 0 2px var(--general-effect-colors-focus-outset-outer-shadow),
+ inset 0 0 0 3px var(--general-effect-colors-focus-outset-inner-shadow);
+}
+
+.tedi-rating__caption {
+ font-size: var(--body-extra-small-size);
+ line-height: var(--body-extra-small-line-height);
+ color: var(--general-text-secondary);
+ text-align: center;
+}
+
+.tedi-rating__star-caption {
+ align-self: stretch;
+ min-height: var(--body-extra-small-line-height);
+ font-size: var(--body-extra-small-size);
+ line-height: var(--body-extra-small-line-height);
+ color: var(--general-text-secondary);
+ text-align: center;
+}
+
+.tedi-rating--number:not(.tedi-rating--vertical) {
+ .tedi-rating__items--captioned {
+ row-gap: var(--layout-grid-gutters-24);
+ padding-bottom: var(--layout-grid-gutters-24);
+ }
+
+ .tedi-rating__caption {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-top: var(--layout-grid-gutters-04);
+ white-space: nowrap;
+ transform: translateX(-50%);
+ }
+}
+
+.tedi-rating--disabled {
+ .tedi-rating__star {
+ color: var(--form-general-border-disabled);
+ }
+
+ .tedi-rating__visual--selected .tedi-rating__star {
+ color: var(--form-general-background-disabled);
+ }
+
+ .tedi-rating__circle {
+ color: var(--general-text-disabled);
+ background-color: transparent;
+ border-color: var(--form-general-border-disabled);
+ }
+
+ .tedi-rating__visual--selected .tedi-rating__circle {
+ background-color: var(--form-general-background-disabled);
+ }
+}
+
+.tedi-rating--readonly {
+ flex-direction: row;
+ align-items: center;
+}
+
+.tedi-rating__summary {
+ display: inline-flex;
+ gap: var(--layout-grid-gutters-08);
+ align-items: center;
+}
+
+.tedi-rating__stars {
+ display: inline-flex;
+ gap: var(--feedback-star-inner-spacing);
+}
+
+.tedi-rating__star-partial {
+ position: relative;
+ display: inline-flex;
+}
+
+.tedi-rating__star-partial-fill {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ display: inline-flex;
+ width: var(--tedi-rating-star-fill);
+ overflow: hidden;
+}
+
+.tedi-rating__summary-text {
+ font-size: var(--heading-h6-size);
+ font-weight: var(--heading-h6-weight);
+ line-height: var(--heading-h6-line-height);
+ color: var(--general-text-secondary);
+}
+
+@include breakpoints.media-breakpoint-down(md) {
+ .tedi-rating--number:not(.tedi-rating--readonly, .tedi-rating--vertical) .tedi-rating__items {
+ column-gap: var(--layout-grid-gutters-04);
+ }
+}
\ No newline at end of file
diff --git a/src/tedi/components/form/rating/rating.component.spec.ts b/src/tedi/components/form/rating/rating.component.spec.ts
new file mode 100644
index 000000000..c76e99d93
--- /dev/null
+++ b/src/tedi/components/form/rating/rating.component.spec.ts
@@ -0,0 +1,317 @@
+import { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormControl, ReactiveFormsModule } from "@angular/forms";
+import { RatingComponent } from "./rating.component";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+
+describe("RatingComponent", () => {
+ let fixture: ComponentFixture;
+ let component: RatingComponent;
+ let el: HTMLElement;
+
+ const radios = () =>
+ Array.from(el.querySelectorAll("input[type='radio']"));
+ const radio = (name: string) =>
+ radios().find((input) => input.getAttribute("aria-label") === name)!;
+ const visuals = () =>
+ Array.from(el.querySelectorAll(".tedi-rating__visual"));
+ const isSelected = (v: HTMLElement) =>
+ v.classList.contains("tedi-rating__visual--selected");
+ const isHovered = (v: HTMLElement) =>
+ v.classList.contains("tedi-rating__visual--hover");
+ const hover = (name: string) => {
+ radio(name).closest("label")!.dispatchEvent(new MouseEvent("mouseenter"));
+ fixture.detectChanges();
+ };
+
+ const setInputs = (inputs: Record) => {
+ Object.entries(inputs).forEach(([key, value]) =>
+ fixture.componentRef.setInput(key, value),
+ );
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [RatingComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ });
+
+ fixture = TestBed.createComponent(RatingComponent);
+ component = fixture.componentInstance;
+ el = fixture.nativeElement;
+ fixture.componentRef.setInput("label", "Feedback");
+ fixture.detectChanges();
+ });
+
+ it("renders a labelled radiogroup with 5 items by default", () => {
+ expect(el.getAttribute("role")).toBe("radiogroup");
+ expect(el.getAttribute("aria-label")).toBe("Feedback");
+ expect(radios()).toHaveLength(5);
+ });
+
+ it("defaults to 10 items for the number type", () => {
+ setInputs({ type: "number" });
+ expect(radios()).toHaveLength(10);
+ });
+
+ it("falls back to the type default when count is not a positive integer", () => {
+ for (const count of [0, -3, 3.5]) {
+ setInputs({ count });
+ expect(radios()).toHaveLength(5);
+ }
+ });
+
+ it("checks the radio matching the value", () => {
+ setInputs({ value: 3 });
+ expect(radio("3/5").checked).toBe(true);
+ });
+
+ it("updates the value and emits valueChange when an item is chosen", () => {
+ const emitted: number[] = [];
+ component.value.subscribe((v) => emitted.push(v));
+
+ radio("4/5").click();
+ fixture.detectChanges();
+
+ expect(component.value()).toBe(4);
+ expect(emitted).toContain(4);
+ });
+
+ it("uses itemLabels as accessible names and falls back to value/total", () => {
+ setInputs({
+ type: "number",
+ itemLabels: ["Low", "", "", "", "", "", "", "", "", "High"],
+ });
+ expect(radio("Low")).toBeTruthy();
+ expect(radio("High")).toBeTruthy();
+ expect(radio("5/10")).toBeTruthy();
+ });
+
+ it("shows captions only at the endpoints of the number scale", () => {
+ setInputs({
+ type: "number",
+ itemLabels: ["Low", "x", "", "", "", "", "", "", "", "High"],
+ });
+ const captions = Array.from(
+ el.querySelectorAll(".tedi-rating__caption"),
+ ).map((c) => c.textContent?.trim());
+ expect(captions).toEqual(["Low", "High"]);
+ expect(el.querySelector(".tedi-rating__items--captioned")).not.toBeNull();
+ });
+
+ it("does not reserve caption space without labels", () => {
+ setInputs({ type: "number" });
+ expect(el.querySelector(".tedi-rating__items--captioned")).toBeNull();
+ });
+
+ it("shows the star caption for the current value and keeps its slot when empty", () => {
+ setInputs({ itemLabels: ["A", "B", "C", "D", "E"] });
+ const caption = () =>
+ el.querySelector(".tedi-rating__star-caption")?.textContent?.trim();
+ expect(caption()).toBe("");
+
+ setInputs({ value: 2 });
+ expect(caption()).toBe("B");
+
+ hover("D");
+ expect(caption()).toBe("D");
+ });
+
+ it("disables every radio when disabled", () => {
+ setInputs({ disabled: true });
+ radios().forEach((input) => expect(input.disabled).toBe(true));
+ expect(el.classList).toContain("tedi-rating--disabled");
+ });
+
+ it("applies the vertical orientation", () => {
+ setInputs({ orientation: "vertical" });
+ expect(el.classList).toContain("tedi-rating--vertical");
+ });
+
+ describe("hover preview", () => {
+ it("marks the hovered range as hover and keeps only committed items in it selected", () => {
+ setInputs({ value: 2 });
+ hover("4/5");
+ expect(visuals().map(isHovered)).toEqual([true, true, true, true, false]);
+ expect(visuals().map(isSelected)).toEqual([
+ true,
+ true,
+ false,
+ false,
+ false,
+ ]);
+ });
+
+ it("shows committed items past the hovered range as unselected, and restores them on leave", () => {
+ setInputs({ value: 4 });
+ hover("2/5");
+ expect(visuals().map(isSelected)).toEqual([
+ true,
+ true,
+ false,
+ false,
+ false,
+ ]);
+
+ el.querySelector(".tedi-rating__items")!.dispatchEvent(
+ new MouseEvent("mouseleave"),
+ );
+ fixture.detectChanges();
+ expect(visuals().map(isSelected)).toEqual([
+ true,
+ true,
+ true,
+ true,
+ false,
+ ]);
+ expect(visuals().some(isHovered)).toBe(false);
+ });
+
+ it("keeps the current pick selected while hovering another icon", () => {
+ setInputs({ type: "icon", value: 3 });
+ hover("5/5");
+ expect(visuals().map(isHovered)).toEqual([
+ false,
+ false,
+ false,
+ false,
+ true,
+ ]);
+ expect(visuals().map(isSelected)).toEqual([
+ false,
+ false,
+ true,
+ false,
+ false,
+ ]);
+ });
+
+ it("does not preview while disabled", () => {
+ setInputs({ disabled: true });
+ hover("3/5");
+ expect(visuals().some(isHovered)).toBe(false);
+ });
+ });
+
+ describe("readOnly summary", () => {
+ it("renders a non-interactive summary with the value, max and rater count", () => {
+ setInputs({
+ label: "Teenuse hinnang",
+ readOnly: true,
+ value: 3.5,
+ ratingsCount: 271,
+ });
+ expect(radios()).toHaveLength(0);
+ expect(el.getAttribute("role")).toBe("img");
+ expect(el.getAttribute("aria-label")).toBe(
+ "Teenuse hinnang: 3,5/5 - 271 hindajat",
+ );
+ });
+
+ it("uses the singular rater form for one rating", () => {
+ setInputs({ readOnly: true, value: 4, ratingsCount: 1 });
+ expect(el.getAttribute("aria-label")).toBe("Feedback: 4/5 - 1 hindaja");
+ });
+
+ it("omits the rater count when ratingsCount is not set", () => {
+ setInputs({ readOnly: true, value: 4 });
+ expect(el.getAttribute("aria-label")).toBe("Feedback: 4/5");
+ });
+
+ it("shows the count number only when showRatingsCountLabel is false", () => {
+ setInputs({
+ readOnly: true,
+ value: 3.5,
+ ratingsCount: 271,
+ showRatingsCountLabel: false,
+ });
+ expect(el.getAttribute("aria-label")).toBe("Feedback: 3,5/5 - 271");
+ });
+
+ it("partially fills the boundary star in the scale variant", () => {
+ setInputs({ readOnly: true, readOnlyVariant: "scale", value: 3.5 });
+ const fills = Array.from(
+ el.querySelectorAll(".tedi-rating__star-partial-fill"),
+ ).map((f) => f.style.getPropertyValue("--tedi-rating-star-fill"));
+ expect(fills).toEqual(["100%", "100%", "100%", "50%"]);
+ });
+
+ it("always renders the summary visual filled, even for a zero rating", () => {
+ setInputs({ type: "icon", readOnly: true, value: 0 });
+ expect(el.getAttribute("aria-label")).toBe("Feedback: 0/5");
+ expect(el.querySelector(".tedi-rating__circle--filled")).not.toBeNull();
+ });
+ });
+});
+
+@Component({
+ standalone: true,
+ imports: [RatingComponent],
+ template: ` `,
+})
+class BareAttributeHostComponent {}
+
+describe("RatingComponent boolean attributes", () => {
+ it("treats a bare readOnly attribute as true", () => {
+ TestBed.configureTestingModule({
+ imports: [BareAttributeHostComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ });
+ const fixture = TestBed.createComponent(BareAttributeHostComponent);
+ fixture.detectChanges();
+ const el: HTMLElement = fixture.nativeElement;
+ expect(el.querySelector("input[type='radio']")).toBeNull();
+ expect(el.querySelector("tedi-rating")?.getAttribute("role")).toBe("img");
+ });
+});
+
+@Component({
+ standalone: true,
+ imports: [RatingComponent, ReactiveFormsModule],
+ template: ` `,
+})
+class TestHostComponent {
+ control = new FormControl(2);
+}
+
+describe("RatingComponent with reactive forms", () => {
+ let fixture: ComponentFixture;
+ let host: TestHostComponent;
+ let el: HTMLElement;
+
+ const radios = () =>
+ Array.from(el.querySelectorAll("input[type='radio']"));
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ });
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+ el = fixture.nativeElement;
+ fixture.detectChanges();
+ });
+
+ it("reflects the form control value", () => {
+ expect(radios()[1].checked).toBe(true);
+ host.control.setValue(5);
+ fixture.detectChanges();
+ expect(radios()[4].checked).toBe(true);
+ });
+
+ it("writes the chosen value back and marks the control touched on blur", () => {
+ radios()[3].click();
+ radios()[3].dispatchEvent(new Event("blur"));
+ fixture.detectChanges();
+ expect(host.control.value).toBe(4);
+ expect(host.control.touched).toBe(true);
+ });
+
+ it("disables the radios when the control is disabled", () => {
+ host.control.disable();
+ fixture.detectChanges();
+ radios().forEach((input) => expect(input.disabled).toBe(true));
+ });
+});
diff --git a/src/tedi/components/form/rating/rating.component.ts b/src/tedi/components/form/rating/rating.component.ts
new file mode 100644
index 000000000..d8c85f015
--- /dev/null
+++ b/src/tedi/components/form/rating/rating.component.ts
@@ -0,0 +1,336 @@
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ forwardRef,
+ inject,
+ input,
+ model,
+ signal,
+ ViewEncapsulation,
+} from "@angular/core";
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
+import { IconComponent, IconSize } from "../../base/icon/icon.component";
+import {
+ breakpointInput,
+ BreakpointInput,
+ BreakpointService,
+} from "../../../services/breakpoint/breakpoint.service";
+import { TediTranslationService } from "../../../services/translation/translation.service";
+import { generateUUID } from "../../../helpers/generate-uuid";
+
+export type RatingType = "star" | "number" | "icon";
+export type RatingOrientation = "horizontal" | "vertical";
+export type RatingReadOnlyVariant = "summary" | "scale";
+
+/** Star glyph size (matches `--feedback-star-size`). */
+const STAR_ICON_SIZE: IconSize = 24;
+/** Glyph size inside the circular number/icon items. */
+const CIRCLE_ICON_SIZE: IconSize = 18;
+
+const DEFAULT_ICONS = [
+ "sentiment_very_dissatisfied",
+ "sentiment_dissatisfied",
+ "sentiment_neutral",
+ "sentiment_satisfied",
+ "sentiment_very_satisfied",
+];
+
+interface RatingItem {
+ position: number;
+ selected: boolean;
+ previewed: boolean;
+ icon: string;
+ accessibleName: string;
+ caption?: string;
+}
+
+@Component({
+ selector: "tedi-rating",
+ standalone: true,
+ imports: [IconComponent],
+ templateUrl: "./rating.component.html",
+ styleUrl: "./rating.component.scss",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => RatingComponent),
+ multi: true,
+ },
+ ],
+ host: {
+ "[class]": "classes()",
+ "[attr.role]": "readOnly() ? 'img' : 'radiogroup'",
+ "[attr.aria-label]": "readOnly() ? label() + ': ' + summary() : label()",
+ },
+})
+export class RatingComponent implements ControlValueAccessor {
+ /** Accessible name for the group; also prefixes the `readOnly` summary. */
+ readonly label = input.required();
+ /**
+ * Visual style. `star` / `number` fill cumulatively; `icon` highlights a single item.
+ * @default star
+ */
+ readonly type = input("star");
+ /**
+ * Number of items (the max rating).
+ * @default 5 (`star` / `icon`), 10 (`number`)
+ */
+ readonly count = input();
+ /**
+ * Selected value (1-based; `0` = none). Supports two-way binding and form controls.
+ * In `readOnly`, the (possibly fractional) average.
+ * @default 0
+ */
+ readonly value = model(0);
+ /** Per-item labels (length = `count`); used as each item's accessible name and as captions. */
+ readonly itemLabels = input();
+ /**
+ * Custom Material Symbol glyph(s): an array (one per item) for `type="icon"`, or a single string to
+ * swap the `type="star"` glyph.
+ * @default sentiment faces (`icon`) / `kid_star` (`star`)
+ */
+ readonly icons = input();
+ /**
+ * Disable interaction and mute the colours.
+ * @default false
+ */
+ readonly disabled = input(false, { transform: booleanAttribute });
+ /**
+ * Show a compact read-only summary (visual + `{value}/{count}` text; text only for `type="number"`) instead
+ * of the interactive scale.
+ * @default false
+ */
+ readonly readOnly = input(false, { transform: booleanAttribute });
+ /** Number of ratings shown in the `readOnly` summary (e.g. `271 hindajat`); omitted when unset. */
+ readonly ratingsCount = input();
+ /**
+ * Show the descriptive raters label next to `ratingsCount` (e.g. `271 hindajat`). Set `false` to
+ * show the number only (`271`).
+ * @default true
+ */
+ readonly showRatingsCountLabel = input(true, { transform: booleanAttribute });
+ /**
+ * `readOnly` layout: `summary` (single visual) or `scale` (full star scale with fractional fill).
+ * @default summary
+ */
+ readonly readOnlyVariant = input("summary");
+ /**
+ * Lay the scale out horizontally or stack it vertically (icon + caption per row). Use `vertical` on
+ * narrow layouts for `type="icon"` with long `itemLabels`. Accepts a `BreakpointInput`, e.g.
+ * `{ xs: 'vertical', md: 'horizontal' }`.
+ * @default horizontal
+ */
+ readonly orientation = input(
+ { xs: "horizontal" as RatingOrientation },
+ {
+ transform: (v: BreakpointInput) => breakpointInput(v),
+ },
+ );
+ /** `name` for the radio inputs. Defaults to a generated id. */
+ readonly name = input();
+
+ protected readonly starIconSize = STAR_ICON_SIZE;
+ protected readonly circleIconSize = CIRCLE_ICON_SIZE;
+
+ private readonly breakpointService = inject(BreakpointService);
+ private readonly translationService = inject(TediTranslationService);
+ private readonly generatedName = `tedi-rating-${generateUUID()}`;
+ private readonly formDisabled = signal(false);
+ private readonly hoverValue = signal(null);
+ private onChange: (value: number) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
+ private readonly interactive = computed(
+ () => !this.isDisabled() && !this.readOnly(),
+ );
+ private readonly isHovering = computed(
+ () => this.interactive() && this.hoverValue() !== null,
+ );
+
+ readonly groupName = computed(() => this.name() ?? this.generatedName);
+
+ readonly total = computed(() => {
+ const count = this.count();
+ const fallback = this.type() === "number" ? 10 : 5;
+ return count !== undefined && Number.isInteger(count) && count > 0
+ ? count
+ : fallback;
+ });
+
+ readonly resolvedOrientation = computed(() => {
+ const value = this.orientation();
+ if (
+ value.xxl !== undefined &&
+ this.breakpointService.isAboveBreakpoint("xxl")()
+ )
+ return value.xxl;
+ if (
+ value.xl !== undefined &&
+ this.breakpointService.isAboveBreakpoint("xl")()
+ )
+ return value.xl;
+ if (
+ value.lg !== undefined &&
+ this.breakpointService.isAboveBreakpoint("lg")()
+ )
+ return value.lg;
+ if (
+ value.md !== undefined &&
+ this.breakpointService.isAboveBreakpoint("md")()
+ )
+ return value.md;
+ if (
+ value.sm !== undefined &&
+ this.breakpointService.isAboveBreakpoint("sm")()
+ )
+ return value.sm;
+ return value.xs;
+ });
+
+ private readonly iconList = computed(() => {
+ const icons = this.icons();
+ return Array.isArray(icons)
+ ? icons
+ : icons !== undefined
+ ? [icons]
+ : undefined;
+ });
+
+ readonly starGlyph = computed(() => this.iconList()?.[0] ?? "kid_star");
+
+ private readonly resolvedIcons = computed(
+ () =>
+ this.iconList() ??
+ (this.total() === DEFAULT_ICONS.length ? DEFAULT_ICONS : undefined),
+ );
+
+ readonly items = computed(() => {
+ const type = this.type();
+ const total = this.total();
+ const current = this.value();
+ const hovering = this.isHovering();
+ const hover = this.hoverValue() ?? 0;
+ const labels = this.itemLabels();
+ const icons = this.resolvedIcons();
+
+ return Array.from({ length: total }, (_, index) => {
+ const position = index + 1;
+ const label = labels?.[index];
+ const isEndpoint = position === 1 || position === total;
+ // While hovering, the hovered range previews the pick: items in it take the hover look (selected or
+ // unselected per the committed value). On cumulative scales items past it read as unselected; the
+ // single-choice icon scale keeps its current pick visible.
+ const previewed = hovering && this.isInRange(position, hover);
+ const selected =
+ this.isInRange(position, current) &&
+ (!hovering || previewed || type === "icon");
+
+ return {
+ position,
+ selected,
+ previewed,
+ icon: icons?.[index] ?? "circle",
+ // Language-neutral fallback (matches the read-only `value/total` summary format).
+ accessibleName: label || `${position}/${total}`,
+ caption:
+ label && (type === "icon" || (type === "number" && isEndpoint))
+ ? label
+ : undefined,
+ };
+ });
+ });
+
+ readonly hasCaptions = computed(
+ () => this.type() !== "star" && !!this.itemLabels()?.some(Boolean),
+ );
+
+ readonly starCaption = computed(() => {
+ const labels = this.itemLabels();
+ if (this.type() !== "star" || !labels?.some(Boolean)) return undefined;
+ const display = this.isHovering() ? this.hoverValue()! : this.value();
+ return display > 0 ? (labels[display - 1] ?? "") : "";
+ });
+
+ readonly summary = computed(() => {
+ const locale = this.translationService.getLanguage();
+ const formattedValue = new Intl.NumberFormat(locale, {
+ maximumFractionDigits: 1,
+ }).format(this.value());
+ const ratingsCount = this.ratingsCount();
+ const countText =
+ ratingsCount !== undefined
+ ? ` - ${
+ this.showRatingsCountLabel()
+ ? this.translationService.translate("rating.raters", ratingsCount)
+ : new Intl.NumberFormat(locale).format(ratingsCount)
+ }`
+ : "";
+ return `${formattedValue}/${this.total()}${countText}`;
+ });
+
+ readonly readOnlyIcon = computed(() => {
+ const position = Math.min(
+ this.total(),
+ Math.max(1, Math.round(this.value())),
+ );
+ return this.resolvedIcons()?.[position - 1] ?? "circle";
+ });
+
+ readonly partialFills = computed(() =>
+ Array.from(
+ { length: this.total() },
+ (_, index) => Math.max(0, Math.min(1, this.value() - index)) * 100,
+ ),
+ );
+
+ readonly classes = computed(() => {
+ const classList = ["tedi-rating", `tedi-rating--${this.type()}`];
+ if (this.readOnly()) {
+ classList.push("tedi-rating--readonly");
+ } else {
+ if (this.resolvedOrientation() === "vertical")
+ classList.push("tedi-rating--vertical");
+ if (this.isDisabled()) classList.push("tedi-rating--disabled");
+ }
+ return classList.join(" ");
+ });
+
+ writeValue(value: number | null): void {
+ this.value.set(value ?? 0);
+ }
+
+ registerOnChange(fn: (value: number) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(disabled: boolean): void {
+ this.formDisabled.set(disabled);
+ }
+
+ select(position: number): void {
+ if (!this.interactive()) return;
+ this.value.set(position);
+ this.onChange(position);
+ }
+
+ setHover(position: number | null): void {
+ this.hoverValue.set(this.interactive() ? position : null);
+ }
+
+ handleBlur(): void {
+ this.onTouched();
+ }
+
+ private isInRange(position: number, target: number): boolean {
+ return this.type() === "icon" ? position === target : position <= target;
+ }
+}
diff --git a/src/tedi/components/form/rating/rating.stories.ts b/src/tedi/components/form/rating/rating.stories.ts
new file mode 100644
index 000000000..0edfc3536
--- /dev/null
+++ b/src/tedi/components/form/rating/rating.stories.ts
@@ -0,0 +1,380 @@
+import {
+ argsToTemplate,
+ Meta,
+ moduleMetadata,
+ StoryObj,
+} from "@storybook/angular";
+import { FormsModule } from "@angular/forms";
+import { RatingComponent, RatingType } from "./rating.component";
+import { TextComponent } from "../../base/text/text.component";
+import { ButtonComponent } from "../../buttons/button/button.component";
+import { CardComponent } from "../../content/card/card.component";
+import { CardContentComponent } from "../../content/card/card-content/card-content.component";
+import { LabelComponent } from "../../content/label/label.component";
+import { CheckboxComponent } from "../checkbox/checkbox.component";
+import { CheckboxCardComponent } from "../checkbox-card/checkbox-card.component";
+import { CheckboxCardGroupComponent } from "../checkbox-card-group/checkbox-card-group.component";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+import { FormFieldComponent } from "../form-field/form-field.component";
+import { TextareaComponent } from "../textarea/textarea.component";
+
+/**
+ * Figma ↗
+ */
+
+const STAR_LABELS = [
+ "Ei jäänud üldse rahule",
+ "Ei jäänud rahule",
+ "Osaliselt rahul",
+ "Jäin rahule",
+ "Jäin väga rahule",
+];
+const ICON_LABELS = ["Väga halb", "Halb", "Keskmine", "Hea", "Väga hea"];
+const NUMBER_LABELS = Array.from({ length: 10 }, (_, index) =>
+ index === 0 ? "Väga halb" : index === 9 ? "Suurepärane" : "",
+);
+
+export default {
+ title: "TEDI-Ready/Components/Form/Rating",
+ component: RatingComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [
+ FormsModule,
+ TextComponent,
+ ButtonComponent,
+ CardComponent,
+ CardContentComponent,
+ LabelComponent,
+ CheckboxComponent,
+ CheckboxCardComponent,
+ CheckboxCardGroupComponent,
+ FeedbackTextComponent,
+ FormFieldComponent,
+ TextareaComponent,
+ ],
+ }),
+ ],
+ parameters: {
+ status: { type: ["breakpointSupport"] },
+ design: {
+ type: "figma",
+ url: "https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.76.92?node-id=15548-139124&m=dev",
+ },
+ },
+ args: {
+ label: "Hinnang",
+ type: "star",
+ value: 0,
+ disabled: false,
+ readOnly: false,
+ showRatingsCountLabel: true,
+ readOnlyVariant: "summary",
+ orientation: "horizontal",
+ },
+ argTypes: {
+ label: { control: "text" },
+ type: { control: "radio", options: ["star", "number", "icon"] },
+ count: {
+ control: { type: "number", min: 1 },
+ description:
+ "Number of items. Defaults to 5 (`star` / `icon`) or 10 (`number`).",
+ },
+ value: { control: { type: "number", min: 0, step: 0.5 } },
+ itemLabels: {
+ control: "object",
+ description:
+ "Per-item labels (length = `count`): accessible names and captions.",
+ },
+ icons: {
+ control: "object",
+ description:
+ 'Material Symbol glyph(s): an array for `type="icon"`, a single string for `type="star"`.',
+ },
+ disabled: { control: "boolean" },
+ readOnly: { control: "boolean" },
+ ratingsCount: {
+ control: { type: "number", min: 0 },
+ description: "Number of ratings shown in the `readOnly` summary.",
+ },
+ showRatingsCountLabel: { control: "boolean" },
+ name: { control: "text" },
+ readOnlyVariant: { control: "radio", options: ["summary", "scale"] },
+ orientation: {
+ control: "radio",
+ options: ["horizontal", "vertical"],
+ description:
+ "Accepts a `BreakpointInput`, e.g. `{ xs: 'vertical', md: 'horizontal' }`.",
+ },
+ },
+ render: (args) => ({
+ props: args,
+ template: ` `,
+ }),
+} as Meta;
+
+type Story = StoryObj;
+
+const rowLabel = (value: number, max: number) =>
+ value === 0 ? "No rating" : `${value} of ${max}`;
+
+const scaleShowcase = (
+ type: RatingType,
+ max: number,
+ itemLabels?: string[],
+): Story => ({
+ parameters: { controls: { disable: true } },
+ render: () => ({
+ props: {
+ rows: Array.from({ length: max + 1 }, (_, value) => ({
+ value,
+ label: rowLabel(value, max),
+ })),
+ type,
+ max,
+ itemLabels,
+ },
+ // Row label sits on top on mobile (so the scale gets the full width) and moves to a left column from md up.
+ template: `
+
+ @for (row of rows; track row.value) {
+
+ }
+
+
+ `,
+ }),
+});
+
+export const Default: Story = {};
+
+export const Stars: Story = scaleShowcase("star", 5, STAR_LABELS);
+
+export const Numbers: Story = scaleShowcase("number", 10, NUMBER_LABELS);
+
+export const Icons: Story = scaleShowcase("icon", 5, ICON_LABELS);
+
+export const States: Story = {
+ parameters: {
+ controls: { disable: true },
+ pseudo: {
+ hover: '[id^="rating-hover"] label',
+ focusVisible: '[id^="rating-focus"] input',
+ },
+ },
+ render: () => ({
+ // One table (as in Figma) so every state row lines up across the three types.
+ props: {
+ types: ["star", "number", "icon"],
+ rows: [
+ { label: "Default" },
+ { label: "Hover", state: "hover" },
+ { label: "Disabled", disabled: true },
+ { label: "Focus", state: "focus" },
+ ],
+ capitalize: (type: string) => type[0].toUpperCase() + type.slice(1),
+ },
+ template: `
+
+
+
+
+
+ @for (type of types; track type) {
+
+ {{ capitalize(type) }}
+
+ }
+
+
+ State
+ @for (type of types; track type) {
+ @for (column of ["Default", "Selected"]; track column) {
+
+ {{ column }}
+
+ }
+ }
+
+
+
+ @for (row of rows; track row.label) {
+
+
+ {{ row.label }}
+
+ @for (type of types; track type) {
+ @for (selected of [0, 1]; track selected) {
+
+
+
+ }
+ }
+
+ }
+
+
+
+ `,
+ }),
+};
+
+/**
+ * `orientation="vertical"` stacks the scale and puts each icon beside its caption, so long labels fit
+ * on narrow (mobile) layouts where the horizontal row would overflow.
+ */
+export const VerticalIcons: Story = {
+ name: "Vertical (icons)",
+ parameters: { controls: { disable: true } },
+ render: () => ({
+ props: {
+ labels: [
+ "Väga halb kogemus",
+ "Halb kogemus",
+ "Keskmine kogemus",
+ "Hea kogemus",
+ "Väga hea kogemus",
+ ],
+ },
+ template: ` `,
+ }),
+};
+
+/**
+ * Two-way binding with `[(value)]`; the component also works with `ngModel` and reactive forms.
+ */
+export const Controlled: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => ({
+ props: { value: 2, labels: STAR_LABELS },
+ template: `
+
+
+ Valitud: {{ value || "—" }}
+
+ `,
+ }),
+};
+
+/**
+ * The read-only `scale` variant renders the whole star scale with the boundary star filled to the
+ * fraction (e.g. `3,5` → three full, one half, one empty), followed by the summary text. Use it to
+ * show an aggregate rating inline; the default `summary` variant shows a single star instead.
+ */
+export const ReadOnlyScale: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => ({
+ props: { values: [4.8, 3.5, 2.5, 1.2] },
+ template: `
+
+ @for (value of values; track value) {
+
+ }
+
+ `,
+ }),
+};
+
+/**
+ * Swap glyphs via `icons`:
+ * - `type="icon"` takes an array (one per item) for a single-highlight scale, here a weather scale.
+ * - `type="star"` takes a single string to change the cumulative glyph (e.g. a pointier `star` or a
+ * `favorite` heart), keeping the fill and hover/focus behaviour.
+ *
+ * Pair with `itemLabels` for accessible names / captions.
+ */
+export const CustomIcons: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => ({
+ props: {
+ weather: ["thunderstorm", "rainy", "cloud", "partly_cloudy_day", "sunny"],
+ iconLabels: ICON_LABELS,
+ starLabels: STAR_LABELS,
+ },
+ template: `
+
+
+
+
+
+ `,
+ }),
+};
+
+export const FeedbackFormExample: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => ({
+ props: {
+ rating: 1,
+ submitted: false,
+ labels: STAR_LABELS,
+ reasons: [
+ { id: "reason-error", label: "Näen veateadet", checked: true },
+ { id: "reason-wrong", label: "Andmed on valed" },
+ { id: "reason-missing", label: "Andmed puuduvad" },
+ { id: "reason-no-right", label: "Õigus puudub" },
+ { id: "reason-not-found", label: "Ei leidnud, mida otsisin" },
+ { id: "reason-complex", label: "Keeruline" },
+ { id: "reason-other", label: "Muu põhjus" },
+ ],
+ },
+ template: `
+
+
+ @if (submitted) {
+
+
Aitäh!
+
Sinu tagasiside aitab portaali paremaks muuta.
+
+ } @else {
+
+
+
Kuidas jäid rahule teenuse kasutamisega?
+
+
+ @if (rating > 0) {
+
+
Mis mõjutas Sinu vastust?
+
Vali sobivad märksõnad
+
+
+ @for (reason of reasons; track reason.id) {
+
+
+ {{ reason.label }}
+
+ }
+
+
+ Soovi korral lisa täpsustus
+
+
+
+
+ Saada tagasiside
+
+ }
+
+ }
+
+
+ `,
+ }),
+};
diff --git a/src/tedi/services/translation/translations.ts b/src/tedi/services/translation/translations.ts
index e2f64985a..b46741b0e 100644
--- a/src/tedi/services/translation/translations.ts
+++ b/src/tedi/services/translation/translations.ts
@@ -1,5 +1,23 @@
import { Language } from "./translation.service";
+const ruPluralRules = new Intl.PluralRules("ru");
+
+/**
+ * Picks the Russian plural form for `count` (1 файл, 2 файла, 5 файлов; 21 → one, 11–14 → many).
+ * Fractions (`other`) take the genitive singular, the same as `few`.
+ */
+const pluralizeRu = (
+ count: number,
+ forms: { one: string; few: string; many: string },
+): string => {
+ const category = ruPluralRules.select(count);
+ return category === "one"
+ ? forms.one
+ : category === "many"
+ ? forms.many
+ : forms.few;
+};
+
export const translationsMap = {
close: {
description: "Used for closing",
@@ -24,6 +42,19 @@ export const translationsMap = {
en: "Open",
ru: "Открыть",
},
+ "rating.raters": {
+ description:
+ 'Suffix in the read-only Rating summary showing how many people rated, e.g. "271 hindajat"',
+ components: ["Rating"],
+ et: (count: number) => `${count} ${count === 1 ? "hindaja" : "hindajat"}`,
+ en: (count: number) => `${count} ${count === 1 ? "rating" : "ratings"}`,
+ ru: (count: number) =>
+ pluralizeRu(count, {
+ one: `${count} оценка`,
+ few: `${count} оценки`,
+ many: `${count} оценок`,
+ }),
+ },
remove: {
description: "Used for removing",
components: ["FileUpload", "Tag"],