From 69d9486ad0229093a1368e03201c7b507f728bd6 Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 29 Jun 2026 10:33:57 +0300 Subject: [PATCH 1/4] feat(rating): new tedi-ready component #330 --- skills/tedi-angular/references/components.md | 20 + tedi/components/form/index.ts | 1 + tedi/components/form/rating/index.ts | 5 + .../form/rating/rating.component.html | 79 +++ .../form/rating/rating.component.scss | 156 ++++++ .../form/rating/rating.component.spec.ts | 530 ++++++++++++++++++ .../form/rating/rating.component.ts | 164 ++++++ tedi/components/form/rating/rating.stories.ts | 267 +++++++++ 8 files changed, 1222 insertions(+) create mode 100644 tedi/components/form/rating/index.ts create mode 100644 tedi/components/form/rating/rating.component.html create mode 100644 tedi/components/form/rating/rating.component.scss create mode 100644 tedi/components/form/rating/rating.component.spec.ts create mode 100644 tedi/components/form/rating/rating.component.ts create mode 100644 tedi/components/form/rating/rating.stories.ts diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index f34c68190..02a069e21 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -750,6 +750,26 @@ Wrapper that joins filters into a connected button group with collapsed borders - `required: boolean = false` - `invalid: boolean = false` +### Rating +**Selector:** `tedi-rating` | ControlValueAccessor +**Model:** `value: number | null` — selected rating (1-based); re-selecting the current value clears it +**Inputs:** +- `variant: RatingVariant = "star"` — "star", "number", or "icon" +- `max: number = 5` — item count for star/number variants +- `items: RatingItem[] = []` — `{ icon: string; label?: string }[]` for the icon variant (count derives from length) +- `startLabel: string`, `endLabel: string` — labels under the first/last item (number variant) +- `ariaLabel: string` — accessible name for the `radiogroup` + +Renders as a `radiogroup` with roving-tabindex keyboard support (arrows wrap, Home/End, Enter/Space). Disabled state comes from the bound `FormControl`. + +```html + + + + + +``` + ### Checkbox **Selector:** `input[type=checkbox][tedi-checkbox]` **Inputs:** diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 6c1d9e523..5d0106234 100644 --- a/tedi/components/form/index.ts +++ b/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/label.component"; export * from "./number-field/number-field.component"; +export * from "./rating"; export * from "./select"; export * from "./toggle/toggle.component"; export * from "./form-field/form-field.component"; diff --git a/tedi/components/form/rating/index.ts b/tedi/components/form/rating/index.ts new file mode 100644 index 000000000..a42bd563f --- /dev/null +++ b/tedi/components/form/rating/index.ts @@ -0,0 +1,5 @@ +export { + RatingComponent, + RatingVariant, + RatingItem, +} from "./rating.component"; diff --git a/tedi/components/form/rating/rating.component.html b/tedi/components/form/rating/rating.component.html new file mode 100644 index 000000000..e179d2a7a --- /dev/null +++ b/tedi/components/form/rating/rating.component.html @@ -0,0 +1,79 @@ +@if (variant() === 'star') { +
+ @for (entry of indexedItems(); track entry.index) { + + } +
+} @else if (variant() === 'number') { +
+ @for (entry of indexedItems(); track entry.index; let first = $first; let last = $last) { +
+ + @if (first && startLabel()) { + {{ startLabel() }} + } + @if (last && endLabel()) { + {{ endLabel() }} + } +
+ } +
+} @else if (variant() === 'icon') { +
+ @for (entry of indexedItems(); track entry.index) { +
+ + @if (entry.item?.label) { + {{ entry.item.label }} + } +
+ } +
+} diff --git a/tedi/components/form/rating/rating.component.scss b/tedi/components/form/rating/rating.component.scss new file mode 100644 index 000000000..be28664bb --- /dev/null +++ b/tedi/components/form/rating/rating.component.scss @@ -0,0 +1,156 @@ +.tedi-rating { + display: block; + max-width: var(--feedback-max-content); +} + +.tedi-rating__items { + display: flex; + flex-wrap: wrap; +} + +.tedi-rating__items--star { + gap: var(--feedback-star-inner-spacing); +} + +.tedi-rating__item { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-04); + align-items: center; + width: var(--feedback-number-indicator-container); +} + +.tedi-rating__star { + display: flex; + align-items: center; + justify-content: center; + padding: var(--feedback-star-inner-spacing); + color: var(--general-icon-secondary); + cursor: pointer; + background: none; + border: 0; + + &:hover { + color: var(--general-icon-brand); + } + + &:focus-visible { + outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + border-radius: var(--tedi-radius-02-default); + } + + &--selected { + color: var(--general-icon-brand); + + &:hover { + color: var(--general-icon-brand); + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + + &:hover { + color: var(--general-icon-secondary); + } + } + + &--selected:disabled { + &:hover { + color: var(--general-icon-brand); + } + } +} + +.tedi-rating__number { + display: flex; + align-items: center; + justify-content: center; + width: var(--button-sm-height); + height: var(--button-sm-height); + min-height: var(--button-sm-height); + padding: 0; + font-size: var(--body-regular-size); + color: var(--general-text-brand); + cursor: pointer; + background: var(--button-main-secondary-background-default); + border: var(--tedi-borders-01) solid var(--button-main-secondary-border-default); + border-radius: var(--button-radius-default); + + &:hover { + background: var(--button-main-secondary-background-hover); + border-color: var(--button-main-secondary-border-hover); + } + + &:focus-visible { + outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &--selected { + color: var(--general-text-white); + background: var(--feedback-indicator); + border-color: var(--feedback-indicator); + + &:hover { + background: var(--feedback-indicator); + border-color: var(--feedback-indicator); + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} + +.tedi-rating__icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: var(--button-sm-height); + height: var(--button-sm-height); + min-height: var(--button-sm-height); + padding: 0; + color: var(--general-text-brand); + cursor: pointer; + background: var(--button-main-secondary-background-default); + border: var(--tedi-borders-01) solid var(--button-main-secondary-border-default); + border-radius: var(--button-radius-default); + + &:hover { + background: var(--button-main-secondary-background-hover); + border-color: var(--button-main-secondary-border-hover); + } + + &:focus-visible { + outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &--selected { + color: var(--general-text-white); + background: var(--feedback-indicator); + border-color: var(--feedback-indicator); + + &:hover { + background: var(--feedback-indicator); + border-color: var(--feedback-indicator); + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} + +.tedi-rating__label { + font-size: var(--body-extra-small-size); + line-height: var(--body-extra-small-line-height); + color: var(--general-text-secondary); + text-align: center; + white-space: nowrap; +} diff --git a/tedi/components/form/rating/rating.component.spec.ts b/tedi/components/form/rating/rating.component.spec.ts new file mode 100644 index 000000000..c19dc6e98 --- /dev/null +++ b/tedi/components/form/rating/rating.component.spec.ts @@ -0,0 +1,530 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { RatingComponent, RatingItem } from "./rating.component"; + +describe("RatingComponent", () => { + let fixture: ComponentFixture; + let component: RatingComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RatingComponent], + }); + + fixture = TestBed.createComponent(RatingComponent); + component = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with null value", () => { + expect(component.value()).toBeNull(); + }); + + it("should have radiogroup role on host", () => { + expect(el.getAttribute("role")).toBe("radiogroup"); + }); + + it("should set aria-label on host", () => { + fixture.componentRef.setInput("ariaLabel", "Rate this"); + fixture.detectChanges(); + + expect(el.getAttribute("aria-label")).toBe("Rate this"); + }); + + describe("star variant", () => { + it("should render 5 star buttons by default", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + expect(stars.length).toBe(5); + }); + + it("should have radio role on each star", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + stars.forEach((star) => { + expect(star.getAttribute("role")).toBe("radio"); + }); + }); + + it("should select star on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[2] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBe(3); + expect(onChange).toHaveBeenCalledWith(3); + }); + + it("should deselect on clicking same star", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + component.writeValue(3); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[2] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBeNull(); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("should fill all stars up to selected value", () => { + component.writeValue(3); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + expect( + stars[0].classList.contains("tedi-rating__star--selected"), + ).toBe(true); + expect( + stars[1].classList.contains("tedi-rating__star--selected"), + ).toBe(true); + expect( + stars[2].classList.contains("tedi-rating__star--selected"), + ).toBe(true); + expect( + stars[3].classList.contains("tedi-rating__star--selected"), + ).toBe(false); + expect( + stars[4].classList.contains("tedi-rating__star--selected"), + ).toBe(false); + }); + + it("should set aria-checked on selected star", () => { + component.writeValue(2); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + expect(stars[1].getAttribute("aria-checked")).toBe("true"); + expect(stars[0].getAttribute("aria-checked")).toBe("false"); + expect(stars[2].getAttribute("aria-checked")).toBe("false"); + }); + + it("should have aria-label with index on each star", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + expect(stars[0].getAttribute("aria-label")).toBe("1 / 5"); + expect(stars[4].getAttribute("aria-label")).toBe("5 / 5"); + }); + + it("should use filled icon variant for selected stars", () => { + component.writeValue(3); + fixture.detectChanges(); + + const icons = el.querySelectorAll("tedi-icon"); + expect(icons[0].classList.contains("tedi-icon--filled")).toBe(true); + expect(icons[2].classList.contains("tedi-icon--filled")).toBe(true); + expect(icons[3].classList.contains("tedi-icon--filled")).toBe(false); + }); + + it("should apply host variant class", () => { + expect(el.classList.contains("tedi-rating--star")).toBe(true); + }); + }); + + describe("max input", () => { + it("should render custom number of stars with max", () => { + fixture.componentRef.setInput("max", 10); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + expect(stars.length).toBe(10); + }); + }); + + describe("number variant", () => { + beforeEach(() => { + fixture.componentRef.setInput("variant", "number"); + fixture.componentRef.setInput("max", 10); + fixture.detectChanges(); + }); + + it("should render 10 number buttons", () => { + const numbers = el.querySelectorAll(".tedi-rating__number"); + expect(numbers.length).toBe(10); + }); + + it("should display numbers 1-10", () => { + const numbers = el.querySelectorAll(".tedi-rating__number"); + expect(numbers[0].textContent?.trim()).toBe("1"); + expect(numbers[9].textContent?.trim()).toBe("10"); + }); + + it("should select number on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const numbers = el.querySelectorAll(".tedi-rating__number"); + (numbers[6] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBe(7); + expect(onChange).toHaveBeenCalledWith(7); + }); + + it("should highlight only selected number", () => { + component.writeValue(5); + fixture.detectChanges(); + + const numbers = el.querySelectorAll(".tedi-rating__number"); + expect( + numbers[4].classList.contains("tedi-rating__number--selected"), + ).toBe(true); + expect( + numbers[3].classList.contains("tedi-rating__number--selected"), + ).toBe(false); + expect( + numbers[5].classList.contains("tedi-rating__number--selected"), + ).toBe(false); + }); + + it("should render startLabel under first item", () => { + fixture.componentRef.setInput("startLabel", "Väga halb"); + fixture.detectChanges(); + + const labels = el.querySelectorAll(".tedi-rating__label"); + expect(labels.length).toBe(1); + expect(labels[0].textContent?.trim()).toBe("Väga halb"); + }); + + it("should render endLabel under last item", () => { + fixture.componentRef.setInput("endLabel", "Suurepärane"); + fixture.detectChanges(); + + const labels = el.querySelectorAll(".tedi-rating__label"); + expect(labels.length).toBe(1); + expect(labels[0].textContent?.trim()).toBe("Suurepärane"); + }); + + it("should render both startLabel and endLabel", () => { + fixture.componentRef.setInput("startLabel", "Väga halb"); + fixture.componentRef.setInput("endLabel", "Suurepärane"); + fixture.detectChanges(); + + const labels = el.querySelectorAll(".tedi-rating__label"); + expect(labels.length).toBe(2); + expect(labels[0].textContent?.trim()).toBe("Väga halb"); + expect(labels[1].textContent?.trim()).toBe("Suurepärane"); + }); + + it("should apply host variant class", () => { + expect(el.classList.contains("tedi-rating--number")).toBe(true); + }); + }); + + describe("icon variant", () => { + const items: RatingItem[] = [ + { icon: "sentiment_very_dissatisfied", label: "Väga halb" }, + { icon: "sentiment_dissatisfied", label: "Halb" }, + { icon: "sentiment_neutral", label: "Keskmine" }, + { icon: "sentiment_satisfied", label: "Hea" }, + { icon: "sentiment_very_satisfied", label: "Väga hea" }, + ]; + + beforeEach(() => { + fixture.componentRef.setInput("variant", "icon"); + fixture.componentRef.setInput("items", items); + fixture.detectChanges(); + }); + + it("should render icon buttons matching items length", () => { + const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); + expect(buttons.length).toBe(5); + }); + + it("should render tedi-icon inside each button", () => { + const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); + const icon = buttons[0].querySelector("tedi-icon"); + expect(icon).toBeTruthy(); + }); + + it("should render labels below buttons", () => { + const labelEls = el.querySelectorAll(".tedi-rating__label"); + expect(labelEls.length).toBe(5); + expect(labelEls[0].textContent?.trim()).toBe("Väga halb"); + expect(labelEls[4].textContent?.trim()).toBe("Väga hea"); + }); + + it("should select icon on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); + (buttons[3] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBe(4); + expect(onChange).toHaveBeenCalledWith(4); + }); + + it("should highlight only selected icon", () => { + component.writeValue(3); + fixture.detectChanges(); + + const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); + expect( + buttons[2].classList.contains("tedi-rating__icon-btn--selected"), + ).toBe(true); + expect( + buttons[1].classList.contains("tedi-rating__icon-btn--selected"), + ).toBe(false); + }); + + it("should use label as aria-label on button", () => { + const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); + expect(buttons[0].getAttribute("aria-label")).toBe("Väga halb"); + }); + + it("should not render labels when items have no label", () => { + fixture.componentRef.setInput("items", [ + { icon: "star" }, + { icon: "favorite" }, + ]); + fixture.detectChanges(); + + const labelEls = el.querySelectorAll(".tedi-rating__label"); + expect(labelEls.length).toBe(0); + }); + + it("should apply host variant class", () => { + expect(el.classList.contains("tedi-rating--icon")).toBe(true); + }); + }); + + describe("disabled state", () => { + it("should disable buttons via setDisabledState", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + stars.forEach((star) => { + expect((star as HTMLButtonElement).disabled).toBe(true); + }); + }); + + it("should not change value when disabled", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + const onChange = jest.fn(); + component.registerOnChange(onChange); + + component.select(3); + expect(component.value()).toBeNull(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should apply disabled host class", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + expect(el.classList.contains("tedi-rating--disabled")).toBe(true); + }); + }); + + describe("roving tabindex", () => { + it("should set tabindex 0 on first item when no selection", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + expect(stars[0].getAttribute("tabindex")).toBe("0"); + expect(stars[1].getAttribute("tabindex")).toBe("-1"); + }); + + it("should set tabindex 0 on selected item", () => { + component.writeValue(3); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + expect(stars[2].getAttribute("tabindex")).toBe("0"); + expect(stars[0].getAttribute("tabindex")).toBe("-1"); + }); + + it("should set tabindex -1 on all items when disabled", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + stars.forEach((star) => { + expect(star.getAttribute("tabindex")).toBe("-1"); + }); + }); + }); + + describe("keyboard navigation", () => { + const dispatchKey = (element: HTMLElement, key: string) => { + element.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true }), + ); + }; + + it("should move focus with ArrowRight", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[0] as HTMLElement).focus(); + dispatchKey(stars[0] as HTMLElement, "ArrowRight"); + expect(document.activeElement).toBe(stars[1]); + }); + + it("should move focus with ArrowLeft", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[2] as HTMLElement).focus(); + dispatchKey(stars[2] as HTMLElement, "ArrowLeft"); + expect(document.activeElement).toBe(stars[1]); + }); + + it("should move focus with ArrowDown", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[0] as HTMLElement).focus(); + dispatchKey(stars[0] as HTMLElement, "ArrowDown"); + expect(document.activeElement).toBe(stars[1]); + }); + + it("should move focus with ArrowUp", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[2] as HTMLElement).focus(); + dispatchKey(stars[2] as HTMLElement, "ArrowUp"); + expect(document.activeElement).toBe(stars[1]); + }); + + it("should wrap from last to first with ArrowRight", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[4] as HTMLElement).focus(); + dispatchKey(stars[4] as HTMLElement, "ArrowRight"); + expect(document.activeElement).toBe(stars[0]); + }); + + it("should wrap from first to last with ArrowLeft", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[0] as HTMLElement).focus(); + dispatchKey(stars[0] as HTMLElement, "ArrowLeft"); + expect(document.activeElement).toBe(stars[4]); + }); + + it("should move to first with Home", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[3] as HTMLElement).focus(); + dispatchKey(stars[3] as HTMLElement, "Home"); + expect(document.activeElement).toBe(stars[0]); + }); + + it("should move to last with End", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[0] as HTMLElement).focus(); + dispatchKey(stars[0] as HTMLElement, "End"); + expect(document.activeElement).toBe(stars[4]); + }); + + it("should select with Enter", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[3] as HTMLElement).focus(); + dispatchKey(stars[3] as HTMLElement, "Enter"); + + expect(component.value()).toBe(4); + expect(onChange).toHaveBeenCalledWith(4); + }); + + it("should select with Space", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[1] as HTMLElement).focus(); + dispatchKey(stars[1] as HTMLElement, " "); + + expect(component.value()).toBe(2); + expect(onChange).toHaveBeenCalledWith(2); + }); + }); + + describe("ControlValueAccessor", () => { + it("should set value via writeValue", () => { + component.writeValue(4); + expect(component.value()).toBe(4); + }); + + it("should handle null writeValue", () => { + component.writeValue(null); + expect(component.value()).toBeNull(); + }); + + it("should call onTouched on blur", () => { + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[0] as HTMLElement).dispatchEvent( + new Event("blur", { bubbles: true }), + ); + + expect(onTouched).toHaveBeenCalled(); + }); + }); +}); + +@Component({ + standalone: true, + imports: [RatingComponent, ReactiveFormsModule], + template: ``, +}) +class TestHostComponent { + control = new FormControl(null); +} + +describe("RatingComponent with ReactiveFormsModule", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should sync FormControl value to component", () => { + host.control.setValue(3); + fixture.detectChanges(); + + const stars = el.querySelectorAll(".tedi-rating__star"); + expect( + stars[0].classList.contains("tedi-rating__star--selected"), + ).toBe(true); + expect( + stars[2].classList.contains("tedi-rating__star--selected"), + ).toBe(true); + expect( + stars[3].classList.contains("tedi-rating__star--selected"), + ).toBe(false); + }); + + it("should sync component selection to FormControl", () => { + const stars = el.querySelectorAll(".tedi-rating__star"); + (stars[3] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(host.control.value).toBe(4); + }); + + it("should disable component when FormControl is disabled", () => { + host.control.disable(); + fixture.detectChanges(); + + const ratingEl = el.querySelector("tedi-rating") as HTMLElement; + expect(ratingEl.classList.contains("tedi-rating--disabled")).toBe(true); + }); +}); diff --git a/tedi/components/form/rating/rating.component.ts b/tedi/components/form/rating/rating.component.ts new file mode 100644 index 000000000..30afef97c --- /dev/null +++ b/tedi/components/form/rating/rating.component.ts @@ -0,0 +1,164 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + forwardRef, + inject, + input, + model, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { IconComponent } from "../../base/icon/icon.component"; + +export type RatingVariant = "star" | "number" | "icon"; + +export interface RatingItem { + icon: string; + label?: string; +} + +@Component({ + selector: "tedi-rating", + standalone: true, + imports: [IconComponent], + templateUrl: "./rating.component.html", + styleUrl: "./rating.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: "tedi-rating", + "[class.tedi-rating--star]": "variant() === 'star'", + "[class.tedi-rating--number]": "variant() === 'number'", + "[class.tedi-rating--icon]": "variant() === 'icon'", + "[class.tedi-rating--disabled]": "isDisabled()", + role: "radiogroup", + "[attr.aria-label]": "ariaLabel()", + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RatingComponent), + multi: true, + }, + ], +}) +export class RatingComponent implements ControlValueAccessor { + readonly value = model(null); + readonly variant = input("star"); + readonly max = input(5); + readonly items = input([]); + readonly startLabel = input(); + readonly endLabel = input(); + readonly ariaLabel = input(); + + private formDisabled = signal(false); + private onChange: (value: number | null) => void = () => {}; + private onTouched: () => void = () => {}; + private hostEl = inject(ElementRef); + + readonly isDisabled = computed(() => this.formDisabled()); + + readonly resolvedMax = computed(() => { + if (this.variant() === "icon") { + return this.items().length || this.max(); + } + return this.max(); + }); + + readonly indexedItems = computed(() => + Array.from({ length: this.resolvedMax() }, (_, i) => ({ + index: i + 1, + item: this.items()[i], + })), + ); + + writeValue(value: number | null): void { + this.value.set(value); + } + + registerOnChange(fn: (value: number | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + } + + select(index: number): void { + if (this.isDisabled()) return; + const newValue = this.value() === index ? null : index; + this.value.set(newValue); + this.onChange(newValue); + this.onTouched(); + } + + isStarFilled(index: number): boolean { + const val = this.value(); + return val !== null && index <= val; + } + + getTabIndex(index: number): number { + if (this.isDisabled()) return -1; + if (this.value() !== null) { + return this.value() === index ? 0 : -1; + } + return index === 1 ? 0 : -1; + } + + handleKeydown(event: KeyboardEvent, currentIndex: number): void { + const max = this.resolvedMax(); + let nextIndex: number | null = null; + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + event.preventDefault(); + nextIndex = currentIndex < max ? currentIndex + 1 : 1; + break; + case "ArrowLeft": + case "ArrowUp": + event.preventDefault(); + nextIndex = currentIndex > 1 ? currentIndex - 1 : max; + break; + case "Home": + event.preventDefault(); + nextIndex = 1; + break; + case "End": + event.preventDefault(); + nextIndex = max; + break; + case "Enter": + case " ": + event.preventDefault(); + this.select(currentIndex); + return; + default: + return; + } + + if (nextIndex !== null) { + this.focusItem(nextIndex); + } + } + + handleBlur(): void { + this.onTouched(); + } + + private focusItem(index: number): void { + const buttons: NodeListOf = + this.hostEl.nativeElement.querySelectorAll('[role="radio"]'); + const button = buttons[index - 1]; + if (button) { + button.focus(); + } + } +} diff --git a/tedi/components/form/rating/rating.stories.ts b/tedi/components/form/rating/rating.stories.ts new file mode 100644 index 000000000..36062b613 --- /dev/null +++ b/tedi/components/form/rating/rating.stories.ts @@ -0,0 +1,267 @@ +import { + Meta, + StoryObj, + argsToTemplate, + moduleMetadata, +} from "@storybook/angular"; +import { NgFor } from "@angular/common"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { RatingComponent, RatingItem } from "./rating.component"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; +import { TextComponent } from "../../base/text/text.component"; +import { AlertComponent } from "../../notifications/alert/alert.component"; + +/** + * Figma ↗ + */ + +export default { + title: "TEDI-Ready/Components/Form/Rating", + component: RatingComponent, + decorators: [ + moduleMetadata({ + imports: [ + RatingComponent, + ReactiveFormsModule, + NgFor, + RowComponent, + ColComponent, + TextComponent, + AlertComponent, + ], + }), + ], + argTypes: { + variant: { + description: "Visual variant of the rating.", + control: "radio", + options: ["star", "number", "icon"], + table: { + category: "inputs", + type: { summary: '"star" | "number" | "icon"' }, + defaultValue: { summary: '"star"' }, + }, + }, + value: { + description: + "Selected rating value (1-based). Supports two-way binding.", + control: "number", + table: { + category: "inputs", + type: { summary: "number | null" }, + defaultValue: { summary: "null" }, + }, + }, + max: { + description: "Maximum number of items for star/number variants.", + control: "number", + table: { + category: "inputs", + type: { summary: "number" }, + defaultValue: { summary: "5" }, + }, + }, + items: { + description: + "Array of rating items for icon variant. Each item has an icon name and optional label.", + control: "object", + table: { + category: "inputs", + type: { summary: "RatingItem[]" }, + defaultValue: { summary: "[]" }, + }, + }, + startLabel: { + description: "Label displayed below the first item (number variant).", + control: "text", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + endLabel: { + description: "Label displayed below the last item (number variant).", + control: "text", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + ariaLabel: { + description: "Accessible label for the rating group.", + control: "text", + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + variant: "star", + value: 3, + ariaLabel: "Rate this item", + }, +}; + +export const StarVariant: Story = { + render: () => ({ + template: ` +
+
+

Empty

+ +
+
+

Partially filled (3/5)

+ +
+
+

Fully filled (5/5)

+ +
+
+ `, + }), +}; + +export const NumberVariant: Story = { + render: () => ({ + template: ` +
+
+

With labels

+ +
+
+

Without labels

+ +
+
+ `, + }), +}; + +const iconItems: RatingItem[] = [ + { icon: "sentiment_very_dissatisfied", label: "Väga halb" }, + { icon: "sentiment_dissatisfied", label: "Halb" }, + { icon: "sentiment_neutral", label: "Keskmine" }, + { icon: "sentiment_satisfied", label: "Hea" }, + { icon: "sentiment_very_satisfied", label: "Väga hea" }, +]; + +export const IconVariant: Story = { + render: () => ({ + props: { + items: iconItems, + }, + template: ` + + `, + }), +}; + +export const WithFormControl: Story = { + render: () => ({ + props: { + control: new FormControl(2), + }, + template: ` +
+ + +
{{ {
+  value: control.value,
+  touched: control.touched,
+  dirty: control.dirty
+} | json }}
+
+
+ `, + }), +}; + +export const Disabled: Story = { + render: () => ({ + props: { + disabledControl: new FormControl({ value: 3, disabled: true }), + disabledNumberControl: new FormControl({ value: 5, disabled: true }), + disabledIconControl: new FormControl({ value: 2, disabled: true }), + items: iconItems, + }, + template: ` +
+
+

Star (disabled)

+ +
+
+

Number (disabled)

+ +
+
+

Icon (disabled)

+ +
+
+ `, + }), +}; + +export const States: Story = { + parameters: { + pseudo: { + hover: ".pseudo-hover [role='radio']", + active: ".pseudo-active [role='radio']", + focusVisible: ".pseudo-focus [role='radio']", + }, + }, + render: () => ({ + props: { + STATES: ["Default", "Hover", "Active", "Focus", "Selected"], + items: iconItems, + }, + template: ` + +

State

+

Star

+

Number

+

Icon

+ + +

{{ state }}

+ + + + + + + + + +
+
+ `, + }), +}; From d91b8658887d86255218cdcc5ee04310b0caf1d1 Mon Sep 17 00:00:00 2001 From: m2rt Date: Tue, 30 Jun 2026 09:25:43 +0300 Subject: [PATCH 2/4] feat(rating): added customizable icon + precision #330 --- skills/tedi-angular/references/components.md | 5 + .../form/rating/rating.component.html | 48 +-- .../form/rating/rating.component.scss | 63 ++-- .../form/rating/rating.component.spec.ts | 293 +++++++++++++----- .../form/rating/rating.component.ts | 145 ++++++++- tedi/components/form/rating/rating.stories.ts | 84 ++++- 6 files changed, 507 insertions(+), 131 deletions(-) diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index 02a069e21..12808a4e2 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -756,6 +756,9 @@ Wrapper that joins filters into a connected button group with collapsed borders **Inputs:** - `variant: RatingVariant = "star"` — "star", "number", or "icon" - `max: number = 5` — item count for star/number variants +- `precision: 1 | 0.5 = 1` — star-variant increment. `0.5` (half stars) is **discouraged** — each half-star target is below the WCAG 2.5.8 24×24px minimum (a dev-mode warning is logged). Ignored by number/icon +- `icon: string = "kid_star"` — Material Symbols icon for the star variant (needs filled + outlined variants for partial fill) +- `color: IconColor = "brand"` — fill color of selected stars (star variant); empty stars stay neutral - `items: RatingItem[] = []` — `{ icon: string; label?: string }[]` for the icon variant (count derives from length) - `startLabel: string`, `endLabel: string` — labels under the first/last item (number variant) - `ariaLabel: string` — accessible name for the `radiogroup` @@ -765,6 +768,8 @@ Renders as a `radiogroup` with roving-tabindex keyboard support (arrows wrap, Ho ```html + + diff --git a/tedi/components/form/rating/rating.component.html b/tedi/components/form/rating/rating.component.html index e179d2a7a..118635dd1 100644 --- a/tedi/components/form/rating/rating.component.html +++ b/tedi/components/form/rating/rating.component.html @@ -1,25 +1,31 @@ @if (variant() === 'star') { -
- @for (entry of indexedItems(); track entry.index) { - +
+ @for (box of starGrid(); track box.index) { + + + + + + + + @for (segment of box.segments; track segment.value) { + + } + }
} @else if (variant() === 'number') { diff --git a/tedi/components/form/rating/rating.component.scss b/tedi/components/form/rating/rating.component.scss index be28664bb..ee21f10dc 100644 --- a/tedi/components/form/rating/rating.component.scss +++ b/tedi/components/form/rating/rating.component.scss @@ -20,48 +20,53 @@ width: var(--feedback-number-indicator-container); } -.tedi-rating__star { - display: flex; - align-items: center; - justify-content: center; - padding: var(--feedback-star-inner-spacing); - color: var(--general-icon-secondary); - cursor: pointer; - background: none; - border: 0; - - &:hover { - color: var(--general-icon-brand); - } +.tedi-rating__star-box { + position: relative; + display: inline-flex; - &:focus-visible { + &:has(.tedi-rating__segment:focus-visible) { outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); outline-offset: var(--tedi-borders-01); border-radius: var(--tedi-radius-02-default); } +} - &--selected { - color: var(--general-icon-brand); +.tedi-rating__star-icon { + display: flex; + align-items: center; + justify-content: center; +} - &:hover { - color: var(--general-icon-brand); - } +.tedi-rating__star-icon--filled { + position: absolute; + top: 0; + bottom: 0; + left: 0; + justify-content: flex-start; + overflow: hidden; + + .tedi-icon { + flex: 0 0 auto; } +} + +.tedi-rating__segment { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + padding: 0; + cursor: pointer; + background: none; + border: 0; &:disabled { cursor: not-allowed; - opacity: 0.4; - - &:hover { - color: var(--general-icon-secondary); - } } +} - &--selected:disabled { - &:hover { - color: var(--general-icon-brand); - } - } +.tedi-rating--disabled .tedi-rating__star-box { + opacity: 0.4; } .tedi-rating__number { diff --git a/tedi/components/form/rating/rating.component.spec.ts b/tedi/components/form/rating/rating.component.spec.ts index c19dc6e98..415df5006 100644 --- a/tedi/components/form/rating/rating.component.spec.ts +++ b/tedi/components/form/rating/rating.component.spec.ts @@ -39,15 +39,25 @@ describe("RatingComponent", () => { }); describe("star variant", () => { - it("should render 5 star buttons by default", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); - expect(stars.length).toBe(5); + const fillWidths = () => + Array.from( + el.querySelectorAll(".tedi-rating__star-icon--filled"), + ).map((node) => node.style.width); + + it("should render 5 star boxes by default", () => { + const boxes = el.querySelectorAll(".tedi-rating__star-box"); + expect(boxes.length).toBe(5); }); - it("should have radio role on each star", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); - stars.forEach((star) => { - expect(star.getAttribute("role")).toBe("radio"); + it("should render 5 segments by default", () => { + const segments = el.querySelectorAll(".tedi-rating__segment"); + expect(segments.length).toBe(5); + }); + + it("should have radio role on each segment", () => { + const segments = el.querySelectorAll(".tedi-rating__segment"); + segments.forEach((segment) => { + expect(segment.getAttribute("role")).toBe("radio"); }); }); @@ -55,8 +65,8 @@ describe("RatingComponent", () => { const onChange = jest.fn(); component.registerOnChange(onChange); - const stars = el.querySelectorAll(".tedi-rating__star"); - (stars[2] as HTMLButtonElement).click(); + const segments = el.querySelectorAll(".tedi-rating__segment"); + (segments[2] as HTMLButtonElement).click(); fixture.detectChanges(); expect(component.value()).toBe(3); @@ -70,8 +80,8 @@ describe("RatingComponent", () => { component.writeValue(3); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); - (stars[2] as HTMLButtonElement).click(); + const segments = el.querySelectorAll(".tedi-rating__segment"); + (segments[2] as HTMLButtonElement).click(); fixture.detectChanges(); expect(component.value()).toBeNull(); @@ -82,52 +92,199 @@ describe("RatingComponent", () => { component.writeValue(3); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); - expect( - stars[0].classList.contains("tedi-rating__star--selected"), - ).toBe(true); - expect( - stars[1].classList.contains("tedi-rating__star--selected"), - ).toBe(true); - expect( - stars[2].classList.contains("tedi-rating__star--selected"), - ).toBe(true); - expect( - stars[3].classList.contains("tedi-rating__star--selected"), - ).toBe(false); - expect( - stars[4].classList.contains("tedi-rating__star--selected"), - ).toBe(false); + expect(fillWidths()).toEqual(["100%", "100%", "100%", "0%", "0%"]); }); - it("should set aria-checked on selected star", () => { + it("should set aria-checked on selected segment", () => { component.writeValue(2); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); - expect(stars[1].getAttribute("aria-checked")).toBe("true"); - expect(stars[0].getAttribute("aria-checked")).toBe("false"); - expect(stars[2].getAttribute("aria-checked")).toBe("false"); + const segments = el.querySelectorAll(".tedi-rating__segment"); + expect(segments[1].getAttribute("aria-checked")).toBe("true"); + expect(segments[0].getAttribute("aria-checked")).toBe("false"); + expect(segments[2].getAttribute("aria-checked")).toBe("false"); }); - it("should have aria-label with index on each star", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); - expect(stars[0].getAttribute("aria-label")).toBe("1 / 5"); - expect(stars[4].getAttribute("aria-label")).toBe("5 / 5"); + it("should have aria-label with value on each segment", () => { + const segments = el.querySelectorAll(".tedi-rating__segment"); + expect(segments[0].getAttribute("aria-label")).toBe("1 / 5"); + expect(segments[4].getAttribute("aria-label")).toBe("5 / 5"); }); - it("should use filled icon variant for selected stars", () => { - component.writeValue(3); + it("should apply host variant class", () => { + expect(el.classList.contains("tedi-rating--star")).toBe(true); + }); + }); + + describe("star precision", () => { + const segments = () => + el.querySelectorAll(".tedi-rating__segment"); + const fillWidths = () => + Array.from( + el.querySelectorAll(".tedi-rating__star-icon--filled"), + ).map((node) => node.style.width); + + beforeEach(() => { + fixture.componentRef.setInput("precision", 0.5); fixture.detectChanges(); + }); - const icons = el.querySelectorAll("tedi-icon"); - expect(icons[0].classList.contains("tedi-icon--filled")).toBe(true); - expect(icons[2].classList.contains("tedi-icon--filled")).toBe(true); - expect(icons[3].classList.contains("tedi-icon--filled")).toBe(false); + it("should render max / precision segments", () => { + expect(segments().length).toBe(10); }); - it("should apply host variant class", () => { - expect(el.classList.contains("tedi-rating--star")).toBe(true); + it("should still render max star boxes", () => { + expect(el.querySelectorAll(".tedi-rating__star-box").length).toBe(5); + }); + + it("should label segments in precision steps", () => { + expect(segments()[0].getAttribute("aria-label")).toBe("0.5 / 5"); + expect(segments()[4].getAttribute("aria-label")).toBe("2.5 / 5"); + expect(segments()[9].getAttribute("aria-label")).toBe("5 / 5"); + }); + + it("should select a half value on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + segments()[4].click(); + fixture.detectChanges(); + + expect(component.value()).toBe(2.5); + expect(onChange).toHaveBeenCalledWith(2.5); + }); + + it("should render a half-filled star for a fractional value", () => { + component.writeValue(2.5); + fixture.detectChanges(); + + expect(fillWidths()).toEqual(["100%", "100%", "50%", "0%", "0%"]); + }); + + it("should preview the hovered value without committing it", () => { + segments()[6].dispatchEvent(new Event("pointerenter")); + fixture.detectChanges(); + + expect(fillWidths()).toEqual(["100%", "100%", "100%", "50%", "0%"]); + expect(component.value()).toBeNull(); + }); + + it("should clear the hover preview on pointerleave", () => { + segments()[6].dispatchEvent(new Event("pointerenter")); + fixture.detectChanges(); + el.querySelector(".tedi-rating__items--star")!.dispatchEvent( + new Event("pointerleave"), + ); + fixture.detectChanges(); + + expect(fillWidths()).toEqual(["0%", "0%", "0%", "0%", "0%"]); + }); + + it("should move focus by one precision step with ArrowRight", () => { + segments()[4].focus(); + segments()[4].dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + ); + expect(document.activeElement).toBe(segments()[5]); + }); + + it("should wrap to the last segment with ArrowLeft from the first", () => { + segments()[0].focus(); + segments()[0].dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }), + ); + expect(document.activeElement).toBe(segments()[9]); + }); + + it("should select the focused half value with Enter", () => { + segments()[4].focus(); + segments()[4].dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(component.value()).toBe(2.5); + }); + }); + + describe("precision is star-only", () => { + it("should ignore precision for the number variant", () => { + fixture.componentRef.setInput("variant", "number"); + fixture.componentRef.setInput("precision", 0.5); + fixture.detectChanges(); + + expect(component.effectivePrecision()).toBe(1); + }); + + it("should fall back to whole stars for out-of-range precision", () => { + fixture.componentRef.setInput("precision", 0.25); + fixture.detectChanges(); + + expect(component.effectivePrecision()).toBe(1); + expect(el.querySelectorAll(".tedi-rating__segment").length).toBe(5); + }); + + it("should warn in dev mode when precision is 0.5", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + fixture.componentRef.setInput("precision", 0.5); + fixture.detectChanges(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("WCAG")); + warn.mockRestore(); + }); + + it("should not warn for precision 1", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + fixture.componentRef.setInput("max", 7); + fixture.detectChanges(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("should not warn for fractional precision off the star variant", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + fixture.componentRef.setInput("variant", "number"); + fixture.componentRef.setInput("precision", 0.5); + fixture.detectChanges(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); + + describe("star customization", () => { + const icons = () => el.querySelectorAll("tedi-icon"); + + it("should use the kid_star icon by default", () => { + expect(icons()[0].textContent?.trim()).toBe("kid_star"); + expect(icons()[1].textContent?.trim()).toBe("kid_star"); + }); + + it("should render a custom icon on both layers", () => { + fixture.componentRef.setInput("icon", "favorite"); + fixture.detectChanges(); + + icons().forEach((icon) => { + expect(icon.textContent?.trim()).toBe("favorite"); + }); + }); + + it("should fill brand and leave the track secondary by default", () => { + expect(icons()[0].classList.contains("tedi-icon--color-secondary")).toBe( + true, + ); + expect(icons()[1].classList.contains("tedi-icon--color-brand")).toBe(true); + }); + + it("should apply a custom fill color", () => { + fixture.componentRef.setInput("color", "danger"); + fixture.detectChanges(); + + expect(icons()[1].classList.contains("tedi-icon--color-danger")).toBe( + true, + ); + expect(icons()[0].classList.contains("tedi-icon--color-secondary")).toBe( + true, + ); }); }); @@ -136,7 +293,7 @@ describe("RatingComponent", () => { fixture.componentRef.setInput("max", 10); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); expect(stars.length).toBe(10); }); }); @@ -305,7 +462,7 @@ describe("RatingComponent", () => { component.setDisabledState(true); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); stars.forEach((star) => { expect((star as HTMLButtonElement).disabled).toBe(true); }); @@ -333,7 +490,7 @@ describe("RatingComponent", () => { describe("roving tabindex", () => { it("should set tabindex 0 on first item when no selection", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); expect(stars[0].getAttribute("tabindex")).toBe("0"); expect(stars[1].getAttribute("tabindex")).toBe("-1"); }); @@ -342,7 +499,7 @@ describe("RatingComponent", () => { component.writeValue(3); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); expect(stars[2].getAttribute("tabindex")).toBe("0"); expect(stars[0].getAttribute("tabindex")).toBe("-1"); }); @@ -351,7 +508,7 @@ describe("RatingComponent", () => { component.setDisabledState(true); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); stars.forEach((star) => { expect(star.getAttribute("tabindex")).toBe("-1"); }); @@ -366,56 +523,56 @@ describe("RatingComponent", () => { }; it("should move focus with ArrowRight", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[0] as HTMLElement).focus(); dispatchKey(stars[0] as HTMLElement, "ArrowRight"); expect(document.activeElement).toBe(stars[1]); }); it("should move focus with ArrowLeft", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[2] as HTMLElement).focus(); dispatchKey(stars[2] as HTMLElement, "ArrowLeft"); expect(document.activeElement).toBe(stars[1]); }); it("should move focus with ArrowDown", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[0] as HTMLElement).focus(); dispatchKey(stars[0] as HTMLElement, "ArrowDown"); expect(document.activeElement).toBe(stars[1]); }); it("should move focus with ArrowUp", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[2] as HTMLElement).focus(); dispatchKey(stars[2] as HTMLElement, "ArrowUp"); expect(document.activeElement).toBe(stars[1]); }); it("should wrap from last to first with ArrowRight", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[4] as HTMLElement).focus(); dispatchKey(stars[4] as HTMLElement, "ArrowRight"); expect(document.activeElement).toBe(stars[0]); }); it("should wrap from first to last with ArrowLeft", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[0] as HTMLElement).focus(); dispatchKey(stars[0] as HTMLElement, "ArrowLeft"); expect(document.activeElement).toBe(stars[4]); }); it("should move to first with Home", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[3] as HTMLElement).focus(); dispatchKey(stars[3] as HTMLElement, "Home"); expect(document.activeElement).toBe(stars[0]); }); it("should move to last with End", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[0] as HTMLElement).focus(); dispatchKey(stars[0] as HTMLElement, "End"); expect(document.activeElement).toBe(stars[4]); @@ -425,7 +582,7 @@ describe("RatingComponent", () => { const onChange = jest.fn(); component.registerOnChange(onChange); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[3] as HTMLElement).focus(); dispatchKey(stars[3] as HTMLElement, "Enter"); @@ -437,7 +594,7 @@ describe("RatingComponent", () => { const onChange = jest.fn(); component.registerOnChange(onChange); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[1] as HTMLElement).focus(); dispatchKey(stars[1] as HTMLElement, " "); @@ -461,7 +618,7 @@ describe("RatingComponent", () => { const onTouched = jest.fn(); component.registerOnTouched(onTouched); - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[0] as HTMLElement).dispatchEvent( new Event("blur", { bubbles: true }), ); @@ -500,20 +657,14 @@ describe("RatingComponent with ReactiveFormsModule", () => { host.control.setValue(3); fixture.detectChanges(); - const stars = el.querySelectorAll(".tedi-rating__star"); - expect( - stars[0].classList.contains("tedi-rating__star--selected"), - ).toBe(true); - expect( - stars[2].classList.contains("tedi-rating__star--selected"), - ).toBe(true); - expect( - stars[3].classList.contains("tedi-rating__star--selected"), - ).toBe(false); + const fills = Array.from( + el.querySelectorAll(".tedi-rating__star-icon--filled"), + ).map((node) => node.style.width); + expect(fills).toEqual(["100%", "100%", "100%", "0%", "0%"]); }); it("should sync component selection to FormControl", () => { - const stars = el.querySelectorAll(".tedi-rating__star"); + const stars = el.querySelectorAll(".tedi-rating__segment"); (stars[3] as HTMLButtonElement).click(); fixture.detectChanges(); diff --git a/tedi/components/form/rating/rating.component.ts b/tedi/components/form/rating/rating.component.ts index 30afef97c..a98c93798 100644 --- a/tedi/components/form/rating/rating.component.ts +++ b/tedi/components/form/rating/rating.component.ts @@ -2,24 +2,39 @@ import { ChangeDetectionStrategy, Component, computed, + effect, ElementRef, forwardRef, inject, input, + isDevMode, model, signal, ViewEncapsulation, } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "../../base/icon/icon.component"; +import { IconColor, IconComponent } from "../../base/icon/icon.component"; export type RatingVariant = "star" | "number" | "icon"; +export type RatingPrecision = 1 | 0.5; + export interface RatingItem { icon: string; label?: string; } +interface StarSegment { + value: number; + leftPercent: number; + widthPercent: number; +} + +interface StarBox { + index: number; + segments: StarSegment[]; +} + @Component({ selector: "tedi-rating", standalone: true, @@ -49,18 +64,37 @@ export class RatingComponent implements ControlValueAccessor { readonly value = model(null); readonly variant = input("star"); readonly max = input(5); + readonly precision = input(1); + readonly icon = input("kid_star"); + readonly color = input("brand"); readonly items = input([]); readonly startLabel = input(); readonly endLabel = input(); readonly ariaLabel = input(); + constructor() { + effect(() => { + if (isDevMode() && this.variant() === "star" && this.precision() === 0.5) { + console.warn( + "[tedi-rating] precision=0.5 makes each half-star target roughly 12px wide, below the WCAG 2.5.8 (24×24px) minimum target size. Prefer precision=1 unless half-star input is essential.", + ); + } + }); + } + private formDisabled = signal(false); + private hoverValue = signal(null); private onChange: (value: number | null) => void = () => {}; private onTouched: () => void = () => {}; private hostEl = inject(ElementRef); readonly isDisabled = computed(() => this.formDisabled()); + readonly effectivePrecision = computed(() => { + if (this.variant() !== "star") return 1; + return this.precision() === 0.5 ? 0.5 : 1; + }); + readonly resolvedMax = computed(() => { if (this.variant() === "icon") { return this.items().length || this.max(); @@ -75,6 +109,26 @@ export class RatingComponent implements ControlValueAccessor { })), ); + readonly starGrid = computed(() => { + const precision = this.effectivePrecision(); + const subCount = Math.round(1 / precision); + return Array.from({ length: this.resolvedMax() }, (_, i) => { + const index = i + 1; + return { + index, + segments: Array.from({ length: subCount }, (_, k) => ({ + value: this.roundToPrecision(i + (k + 1) * precision), + leftPercent: k * precision * 100, + widthPercent: precision * 100, + })), + }; + }); + }); + + private readonly displayValue = computed( + () => this.hoverValue() ?? this.value(), + ); + writeValue(value: number | null): void { this.value.set(value); } @@ -91,17 +145,33 @@ export class RatingComponent implements ControlValueAccessor { this.formDisabled.set(disabled); } - select(index: number): void { + select(value: number): void { if (this.isDisabled()) return; - const newValue = this.value() === index ? null : index; + const newValue = this.value() === value ? null : value; this.value.set(newValue); this.onChange(newValue); this.onTouched(); } - isStarFilled(index: number): boolean { + getStarFill(starIndex: number): number { + const display = this.displayValue(); + if (display === null) return 0; + const fill = display - (starIndex - 1); + return Math.max(0, Math.min(1, fill)) * 100; + } + + isSegmentChecked(segmentValue: number): boolean { const val = this.value(); - return val !== null && index <= val; + return val !== null && segmentValue === this.roundToPrecision(val); + } + + getSegmentTabIndex(segmentValue: number): number { + if (this.isDisabled()) return -1; + const val = this.value(); + if (val !== null) { + return segmentValue === this.roundToPrecision(val) ? 0 : -1; + } + return segmentValue === this.effectivePrecision() ? 0 : -1; } getTabIndex(index: number): number { @@ -112,6 +182,57 @@ export class RatingComponent implements ControlValueAccessor { return index === 1 ? 0 : -1; } + setHover(value: number): void { + if (this.isDisabled()) return; + this.hoverValue.set(value); + } + + clearHover(): void { + this.hoverValue.set(null); + } + + handleStarKeydown(event: KeyboardEvent, currentValue: number): void { + const precision = this.effectivePrecision(); + const max = this.resolvedMax(); + let nextValue: number | null = null; + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + event.preventDefault(); + nextValue = + currentValue < max + ? this.roundToPrecision(currentValue + precision) + : precision; + break; + case "ArrowLeft": + case "ArrowUp": + event.preventDefault(); + nextValue = + currentValue > precision + ? this.roundToPrecision(currentValue - precision) + : max; + break; + case "Home": + event.preventDefault(); + nextValue = precision; + break; + case "End": + event.preventDefault(); + nextValue = max; + break; + case "Enter": + case " ": + event.preventDefault(); + this.select(currentValue); + return; + default: + return; + } + + this.focusSegment(nextValue); + } + handleKeydown(event: KeyboardEvent, currentIndex: number): void { const max = this.resolvedMax(); let nextIndex: number | null = null; @@ -144,15 +265,23 @@ export class RatingComponent implements ControlValueAccessor { return; } - if (nextIndex !== null) { - this.focusItem(nextIndex); - } + this.focusItem(nextIndex); } handleBlur(): void { this.onTouched(); } + private roundToPrecision(value: number): number { + const precision = this.effectivePrecision(); + const steps = Math.round(value / precision); + return Number((steps * precision).toFixed(10)); + } + + private focusSegment(value: number): void { + this.focusItem(Math.round(value / this.effectivePrecision())); + } + private focusItem(index: number): void { const buttons: NodeListOf = this.hostEl.nativeElement.querySelectorAll('[role="radio"]'); diff --git a/tedi/components/form/rating/rating.stories.ts b/tedi/components/form/rating/rating.stories.ts index 36062b613..5c59702db 100644 --- a/tedi/components/form/rating/rating.stories.ts +++ b/tedi/components/form/rating/rating.stories.ts @@ -62,6 +62,48 @@ export default { defaultValue: { summary: "5" }, }, }, + precision: { + description: + "Star-variant increment. Only 1 or 0.5 are supported; 0.5 (half stars) is discouraged — each half-star target falls below the WCAG 2.5.8 24×24px minimum. Ignored by other variants.", + control: "inline-radio", + options: [1, 0.5], + table: { + category: "inputs", + type: { summary: "1 | 0.5" }, + defaultValue: { summary: "1" }, + }, + }, + icon: { + description: + "Material Symbols icon for the star variant. Needs both filled and outlined variants for partial fill to read correctly.", + control: "text", + table: { + category: "inputs", + type: { summary: "string" }, + defaultValue: { summary: '"kid_star"' }, + }, + }, + color: { + description: + "Fill color of selected stars (star variant). Empty stars stay neutral.", + control: "select", + options: [ + "brand", + "brand-dark", + "success", + "warning", + "warning-dark", + "danger", + "primary", + "secondary", + "tertiary", + ], + table: { + category: "inputs", + type: { summary: "IconColor" }, + defaultValue: { summary: '"brand"' }, + }, + }, items: { description: "Array of rating items for icon variant. Each item has an icon name and optional label.", @@ -134,6 +176,44 @@ export const StarVariant: Story = { }), }; +export const HalfStar: Story = { + render: () => ({ + template: ` +
+
+

Empty

+ +
+
+

2.5 / 5

+ +
+
+

3.5 / 5

+ +
+
+ `, + }), +}; + +export const CustomIcon: Story = { + render: () => ({ + template: ` +
+
+

Hearts

+ +
+
+

Thumbs up

+ +
+
+ `, + }), +}; + export const NumberVariant: Story = { render: () => ({ template: ` @@ -185,11 +265,11 @@ export const IconVariant: Story = { export const WithFormControl: Story = { render: () => ({ props: { - control: new FormControl(2), + control: new FormControl(2.5), }, template: `
- +
{{ {
   value: control.value,

From 26f2c460e4f37c0d24459efc10e368d1a4501991 Mon Sep 17 00:00:00 2001
From: m2rt 
Date: Fri, 9 Oct 2026 10:37:20 +0300
Subject: [PATCH 3/4] feat(rating): updated to match React and Figma #330 *
 added RU pluralism helper to translations

---
 src/tedi/components/form/rating/index.ts      |   1 +
 .../form/rating/rating.component.html         | 114 +++
 .../form/rating/rating.component.scss         | 234 ++++++
 .../form/rating/rating.component.spec.ts      | 317 ++++++++
 .../form/rating/rating.component.ts           | 335 +++++++++
 .../components/form/rating/rating.stories.ts  | 380 ++++++++++
 src/tedi/services/translation/translations.ts |  31 +
 tedi/components/form/rating/index.ts          |   5 -
 .../form/rating/rating.component.html         |  85 ---
 .../form/rating/rating.component.scss         | 161 -----
 .../form/rating/rating.component.spec.ts      | 681 ------------------
 .../form/rating/rating.component.ts           | 293 --------
 tedi/components/form/rating/rating.stories.ts | 347 ---------
 13 files changed, 1412 insertions(+), 1572 deletions(-)
 create mode 100644 src/tedi/components/form/rating/index.ts
 create mode 100644 src/tedi/components/form/rating/rating.component.html
 create mode 100644 src/tedi/components/form/rating/rating.component.scss
 create mode 100644 src/tedi/components/form/rating/rating.component.spec.ts
 create mode 100644 src/tedi/components/form/rating/rating.component.ts
 create mode 100644 src/tedi/components/form/rating/rating.stories.ts
 delete mode 100644 tedi/components/form/rating/index.ts
 delete mode 100644 tedi/components/form/rating/rating.component.html
 delete mode 100644 tedi/components/form/rating/rating.component.scss
 delete mode 100644 tedi/components/form/rating/rating.component.spec.ts
 delete mode 100644 tedi/components/form/rating/rating.component.ts
 delete mode 100644 tedi/components/form/rating/rating.stories.ts

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()) {
+  
+} @else {
+  
+ @for (item of items(); track item.position) { + + } +
+ + @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..f91b6e663 --- /dev/null +++ b/src/tedi/components/form/rating/rating.component.ts @@ -0,0 +1,335 @@ +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) 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) { +
+

{{ row.label }}

+ +
+ } +
+

Read only

+
+ + + +
+
+
+ `, + }), +}); + +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) { + + } + + + + @for (type of types; track type) { + @for (column of ["Default", "Selected"]; track column) { + + } + } + + + + @for (row of rows; track row.label) { + + + @for (type of types; track type) { + @for (selected of [0, 1]; track selected) { + + } + } + + } + +
+

{{ capitalize(type) }}

+
State + {{ column }} +
+ {{ row.label }} + + +
+
+ `, + }), +}; + +/** + * `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) { + + } + + + + + + +
+ +
+ } +
+ } +
+
+ `, + }), +}; 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"], diff --git a/tedi/components/form/rating/index.ts b/tedi/components/form/rating/index.ts deleted file mode 100644 index a42bd563f..000000000 --- a/tedi/components/form/rating/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - RatingComponent, - RatingVariant, - RatingItem, -} from "./rating.component"; diff --git a/tedi/components/form/rating/rating.component.html b/tedi/components/form/rating/rating.component.html deleted file mode 100644 index 118635dd1..000000000 --- a/tedi/components/form/rating/rating.component.html +++ /dev/null @@ -1,85 +0,0 @@ -@if (variant() === 'star') { -
- @for (box of starGrid(); track box.index) { - - - - - - - - @for (segment of box.segments; track segment.value) { - - } - - } -
-} @else if (variant() === 'number') { -
- @for (entry of indexedItems(); track entry.index; let first = $first; let last = $last) { -
- - @if (first && startLabel()) { - {{ startLabel() }} - } - @if (last && endLabel()) { - {{ endLabel() }} - } -
- } -
-} @else if (variant() === 'icon') { -
- @for (entry of indexedItems(); track entry.index) { -
- - @if (entry.item?.label) { - {{ entry.item.label }} - } -
- } -
-} diff --git a/tedi/components/form/rating/rating.component.scss b/tedi/components/form/rating/rating.component.scss deleted file mode 100644 index ee21f10dc..000000000 --- a/tedi/components/form/rating/rating.component.scss +++ /dev/null @@ -1,161 +0,0 @@ -.tedi-rating { - display: block; - max-width: var(--feedback-max-content); -} - -.tedi-rating__items { - display: flex; - flex-wrap: wrap; -} - -.tedi-rating__items--star { - gap: var(--feedback-star-inner-spacing); -} - -.tedi-rating__item { - display: flex; - flex-direction: column; - gap: var(--layout-grid-gutters-04); - align-items: center; - width: var(--feedback-number-indicator-container); -} - -.tedi-rating__star-box { - position: relative; - display: inline-flex; - - &:has(.tedi-rating__segment:focus-visible) { - outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); - outline-offset: var(--tedi-borders-01); - border-radius: var(--tedi-radius-02-default); - } -} - -.tedi-rating__star-icon { - display: flex; - align-items: center; - justify-content: center; -} - -.tedi-rating__star-icon--filled { - position: absolute; - top: 0; - bottom: 0; - left: 0; - justify-content: flex-start; - overflow: hidden; - - .tedi-icon { - flex: 0 0 auto; - } -} - -.tedi-rating__segment { - position: absolute; - top: 0; - bottom: 0; - z-index: 1; - padding: 0; - cursor: pointer; - background: none; - border: 0; - - &:disabled { - cursor: not-allowed; - } -} - -.tedi-rating--disabled .tedi-rating__star-box { - opacity: 0.4; -} - -.tedi-rating__number { - display: flex; - align-items: center; - justify-content: center; - width: var(--button-sm-height); - height: var(--button-sm-height); - min-height: var(--button-sm-height); - padding: 0; - font-size: var(--body-regular-size); - color: var(--general-text-brand); - cursor: pointer; - background: var(--button-main-secondary-background-default); - border: var(--tedi-borders-01) solid var(--button-main-secondary-border-default); - border-radius: var(--button-radius-default); - - &:hover { - background: var(--button-main-secondary-background-hover); - border-color: var(--button-main-secondary-border-hover); - } - - &:focus-visible { - outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); - outline-offset: var(--tedi-borders-01); - } - - &--selected { - color: var(--general-text-white); - background: var(--feedback-indicator); - border-color: var(--feedback-indicator); - - &:hover { - background: var(--feedback-indicator); - border-color: var(--feedback-indicator); - } - } - - &:disabled { - cursor: not-allowed; - opacity: 0.4; - } -} - -.tedi-rating__icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: var(--button-sm-height); - height: var(--button-sm-height); - min-height: var(--button-sm-height); - padding: 0; - color: var(--general-text-brand); - cursor: pointer; - background: var(--button-main-secondary-background-default); - border: var(--tedi-borders-01) solid var(--button-main-secondary-border-default); - border-radius: var(--button-radius-default); - - &:hover { - background: var(--button-main-secondary-background-hover); - border-color: var(--button-main-secondary-border-hover); - } - - &:focus-visible { - outline: calc(2 * var(--tedi-borders-01)) solid var(--tedi-primary-500); - outline-offset: var(--tedi-borders-01); - } - - &--selected { - color: var(--general-text-white); - background: var(--feedback-indicator); - border-color: var(--feedback-indicator); - - &:hover { - background: var(--feedback-indicator); - border-color: var(--feedback-indicator); - } - } - - &:disabled { - cursor: not-allowed; - opacity: 0.4; - } -} - -.tedi-rating__label { - font-size: var(--body-extra-small-size); - line-height: var(--body-extra-small-line-height); - color: var(--general-text-secondary); - text-align: center; - white-space: nowrap; -} diff --git a/tedi/components/form/rating/rating.component.spec.ts b/tedi/components/form/rating/rating.component.spec.ts deleted file mode 100644 index 415df5006..000000000 --- a/tedi/components/form/rating/rating.component.spec.ts +++ /dev/null @@ -1,681 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Component } from "@angular/core"; -import { FormControl, ReactiveFormsModule } from "@angular/forms"; -import { RatingComponent, RatingItem } from "./rating.component"; - -describe("RatingComponent", () => { - let fixture: ComponentFixture; - let component: RatingComponent; - let el: HTMLElement; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RatingComponent], - }); - - fixture = TestBed.createComponent(RatingComponent); - component = fixture.componentInstance; - el = fixture.nativeElement; - fixture.detectChanges(); - }); - - it("should create component", () => { - expect(component).toBeTruthy(); - }); - - it("should initialize with null value", () => { - expect(component.value()).toBeNull(); - }); - - it("should have radiogroup role on host", () => { - expect(el.getAttribute("role")).toBe("radiogroup"); - }); - - it("should set aria-label on host", () => { - fixture.componentRef.setInput("ariaLabel", "Rate this"); - fixture.detectChanges(); - - expect(el.getAttribute("aria-label")).toBe("Rate this"); - }); - - describe("star variant", () => { - const fillWidths = () => - Array.from( - el.querySelectorAll(".tedi-rating__star-icon--filled"), - ).map((node) => node.style.width); - - it("should render 5 star boxes by default", () => { - const boxes = el.querySelectorAll(".tedi-rating__star-box"); - expect(boxes.length).toBe(5); - }); - - it("should render 5 segments by default", () => { - const segments = el.querySelectorAll(".tedi-rating__segment"); - expect(segments.length).toBe(5); - }); - - it("should have radio role on each segment", () => { - const segments = el.querySelectorAll(".tedi-rating__segment"); - segments.forEach((segment) => { - expect(segment.getAttribute("role")).toBe("radio"); - }); - }); - - it("should select star on click", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - const segments = el.querySelectorAll(".tedi-rating__segment"); - (segments[2] as HTMLButtonElement).click(); - fixture.detectChanges(); - - expect(component.value()).toBe(3); - expect(onChange).toHaveBeenCalledWith(3); - }); - - it("should deselect on clicking same star", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - component.writeValue(3); - fixture.detectChanges(); - - const segments = el.querySelectorAll(".tedi-rating__segment"); - (segments[2] as HTMLButtonElement).click(); - fixture.detectChanges(); - - expect(component.value()).toBeNull(); - expect(onChange).toHaveBeenCalledWith(null); - }); - - it("should fill all stars up to selected value", () => { - component.writeValue(3); - fixture.detectChanges(); - - expect(fillWidths()).toEqual(["100%", "100%", "100%", "0%", "0%"]); - }); - - it("should set aria-checked on selected segment", () => { - component.writeValue(2); - fixture.detectChanges(); - - const segments = el.querySelectorAll(".tedi-rating__segment"); - expect(segments[1].getAttribute("aria-checked")).toBe("true"); - expect(segments[0].getAttribute("aria-checked")).toBe("false"); - expect(segments[2].getAttribute("aria-checked")).toBe("false"); - }); - - it("should have aria-label with value on each segment", () => { - const segments = el.querySelectorAll(".tedi-rating__segment"); - expect(segments[0].getAttribute("aria-label")).toBe("1 / 5"); - expect(segments[4].getAttribute("aria-label")).toBe("5 / 5"); - }); - - it("should apply host variant class", () => { - expect(el.classList.contains("tedi-rating--star")).toBe(true); - }); - }); - - describe("star precision", () => { - const segments = () => - el.querySelectorAll(".tedi-rating__segment"); - const fillWidths = () => - Array.from( - el.querySelectorAll(".tedi-rating__star-icon--filled"), - ).map((node) => node.style.width); - - beforeEach(() => { - fixture.componentRef.setInput("precision", 0.5); - fixture.detectChanges(); - }); - - it("should render max / precision segments", () => { - expect(segments().length).toBe(10); - }); - - it("should still render max star boxes", () => { - expect(el.querySelectorAll(".tedi-rating__star-box").length).toBe(5); - }); - - it("should label segments in precision steps", () => { - expect(segments()[0].getAttribute("aria-label")).toBe("0.5 / 5"); - expect(segments()[4].getAttribute("aria-label")).toBe("2.5 / 5"); - expect(segments()[9].getAttribute("aria-label")).toBe("5 / 5"); - }); - - it("should select a half value on click", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - segments()[4].click(); - fixture.detectChanges(); - - expect(component.value()).toBe(2.5); - expect(onChange).toHaveBeenCalledWith(2.5); - }); - - it("should render a half-filled star for a fractional value", () => { - component.writeValue(2.5); - fixture.detectChanges(); - - expect(fillWidths()).toEqual(["100%", "100%", "50%", "0%", "0%"]); - }); - - it("should preview the hovered value without committing it", () => { - segments()[6].dispatchEvent(new Event("pointerenter")); - fixture.detectChanges(); - - expect(fillWidths()).toEqual(["100%", "100%", "100%", "50%", "0%"]); - expect(component.value()).toBeNull(); - }); - - it("should clear the hover preview on pointerleave", () => { - segments()[6].dispatchEvent(new Event("pointerenter")); - fixture.detectChanges(); - el.querySelector(".tedi-rating__items--star")!.dispatchEvent( - new Event("pointerleave"), - ); - fixture.detectChanges(); - - expect(fillWidths()).toEqual(["0%", "0%", "0%", "0%", "0%"]); - }); - - it("should move focus by one precision step with ArrowRight", () => { - segments()[4].focus(); - segments()[4].dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), - ); - expect(document.activeElement).toBe(segments()[5]); - }); - - it("should wrap to the last segment with ArrowLeft from the first", () => { - segments()[0].focus(); - segments()[0].dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }), - ); - expect(document.activeElement).toBe(segments()[9]); - }); - - it("should select the focused half value with Enter", () => { - segments()[4].focus(); - segments()[4].dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), - ); - expect(component.value()).toBe(2.5); - }); - }); - - describe("precision is star-only", () => { - it("should ignore precision for the number variant", () => { - fixture.componentRef.setInput("variant", "number"); - fixture.componentRef.setInput("precision", 0.5); - fixture.detectChanges(); - - expect(component.effectivePrecision()).toBe(1); - }); - - it("should fall back to whole stars for out-of-range precision", () => { - fixture.componentRef.setInput("precision", 0.25); - fixture.detectChanges(); - - expect(component.effectivePrecision()).toBe(1); - expect(el.querySelectorAll(".tedi-rating__segment").length).toBe(5); - }); - - it("should warn in dev mode when precision is 0.5", () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - fixture.componentRef.setInput("precision", 0.5); - fixture.detectChanges(); - - expect(warn).toHaveBeenCalledWith(expect.stringContaining("WCAG")); - warn.mockRestore(); - }); - - it("should not warn for precision 1", () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - fixture.componentRef.setInput("max", 7); - fixture.detectChanges(); - - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); - }); - - it("should not warn for fractional precision off the star variant", () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - fixture.componentRef.setInput("variant", "number"); - fixture.componentRef.setInput("precision", 0.5); - fixture.detectChanges(); - - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); - }); - }); - - describe("star customization", () => { - const icons = () => el.querySelectorAll("tedi-icon"); - - it("should use the kid_star icon by default", () => { - expect(icons()[0].textContent?.trim()).toBe("kid_star"); - expect(icons()[1].textContent?.trim()).toBe("kid_star"); - }); - - it("should render a custom icon on both layers", () => { - fixture.componentRef.setInput("icon", "favorite"); - fixture.detectChanges(); - - icons().forEach((icon) => { - expect(icon.textContent?.trim()).toBe("favorite"); - }); - }); - - it("should fill brand and leave the track secondary by default", () => { - expect(icons()[0].classList.contains("tedi-icon--color-secondary")).toBe( - true, - ); - expect(icons()[1].classList.contains("tedi-icon--color-brand")).toBe(true); - }); - - it("should apply a custom fill color", () => { - fixture.componentRef.setInput("color", "danger"); - fixture.detectChanges(); - - expect(icons()[1].classList.contains("tedi-icon--color-danger")).toBe( - true, - ); - expect(icons()[0].classList.contains("tedi-icon--color-secondary")).toBe( - true, - ); - }); - }); - - describe("max input", () => { - it("should render custom number of stars with max", () => { - fixture.componentRef.setInput("max", 10); - fixture.detectChanges(); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - expect(stars.length).toBe(10); - }); - }); - - describe("number variant", () => { - beforeEach(() => { - fixture.componentRef.setInput("variant", "number"); - fixture.componentRef.setInput("max", 10); - fixture.detectChanges(); - }); - - it("should render 10 number buttons", () => { - const numbers = el.querySelectorAll(".tedi-rating__number"); - expect(numbers.length).toBe(10); - }); - - it("should display numbers 1-10", () => { - const numbers = el.querySelectorAll(".tedi-rating__number"); - expect(numbers[0].textContent?.trim()).toBe("1"); - expect(numbers[9].textContent?.trim()).toBe("10"); - }); - - it("should select number on click", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - const numbers = el.querySelectorAll(".tedi-rating__number"); - (numbers[6] as HTMLButtonElement).click(); - fixture.detectChanges(); - - expect(component.value()).toBe(7); - expect(onChange).toHaveBeenCalledWith(7); - }); - - it("should highlight only selected number", () => { - component.writeValue(5); - fixture.detectChanges(); - - const numbers = el.querySelectorAll(".tedi-rating__number"); - expect( - numbers[4].classList.contains("tedi-rating__number--selected"), - ).toBe(true); - expect( - numbers[3].classList.contains("tedi-rating__number--selected"), - ).toBe(false); - expect( - numbers[5].classList.contains("tedi-rating__number--selected"), - ).toBe(false); - }); - - it("should render startLabel under first item", () => { - fixture.componentRef.setInput("startLabel", "Väga halb"); - fixture.detectChanges(); - - const labels = el.querySelectorAll(".tedi-rating__label"); - expect(labels.length).toBe(1); - expect(labels[0].textContent?.trim()).toBe("Väga halb"); - }); - - it("should render endLabel under last item", () => { - fixture.componentRef.setInput("endLabel", "Suurepärane"); - fixture.detectChanges(); - - const labels = el.querySelectorAll(".tedi-rating__label"); - expect(labels.length).toBe(1); - expect(labels[0].textContent?.trim()).toBe("Suurepärane"); - }); - - it("should render both startLabel and endLabel", () => { - fixture.componentRef.setInput("startLabel", "Väga halb"); - fixture.componentRef.setInput("endLabel", "Suurepärane"); - fixture.detectChanges(); - - const labels = el.querySelectorAll(".tedi-rating__label"); - expect(labels.length).toBe(2); - expect(labels[0].textContent?.trim()).toBe("Väga halb"); - expect(labels[1].textContent?.trim()).toBe("Suurepärane"); - }); - - it("should apply host variant class", () => { - expect(el.classList.contains("tedi-rating--number")).toBe(true); - }); - }); - - describe("icon variant", () => { - const items: RatingItem[] = [ - { icon: "sentiment_very_dissatisfied", label: "Väga halb" }, - { icon: "sentiment_dissatisfied", label: "Halb" }, - { icon: "sentiment_neutral", label: "Keskmine" }, - { icon: "sentiment_satisfied", label: "Hea" }, - { icon: "sentiment_very_satisfied", label: "Väga hea" }, - ]; - - beforeEach(() => { - fixture.componentRef.setInput("variant", "icon"); - fixture.componentRef.setInput("items", items); - fixture.detectChanges(); - }); - - it("should render icon buttons matching items length", () => { - const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); - expect(buttons.length).toBe(5); - }); - - it("should render tedi-icon inside each button", () => { - const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); - const icon = buttons[0].querySelector("tedi-icon"); - expect(icon).toBeTruthy(); - }); - - it("should render labels below buttons", () => { - const labelEls = el.querySelectorAll(".tedi-rating__label"); - expect(labelEls.length).toBe(5); - expect(labelEls[0].textContent?.trim()).toBe("Väga halb"); - expect(labelEls[4].textContent?.trim()).toBe("Väga hea"); - }); - - it("should select icon on click", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); - (buttons[3] as HTMLButtonElement).click(); - fixture.detectChanges(); - - expect(component.value()).toBe(4); - expect(onChange).toHaveBeenCalledWith(4); - }); - - it("should highlight only selected icon", () => { - component.writeValue(3); - fixture.detectChanges(); - - const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); - expect( - buttons[2].classList.contains("tedi-rating__icon-btn--selected"), - ).toBe(true); - expect( - buttons[1].classList.contains("tedi-rating__icon-btn--selected"), - ).toBe(false); - }); - - it("should use label as aria-label on button", () => { - const buttons = el.querySelectorAll(".tedi-rating__icon-btn"); - expect(buttons[0].getAttribute("aria-label")).toBe("Väga halb"); - }); - - it("should not render labels when items have no label", () => { - fixture.componentRef.setInput("items", [ - { icon: "star" }, - { icon: "favorite" }, - ]); - fixture.detectChanges(); - - const labelEls = el.querySelectorAll(".tedi-rating__label"); - expect(labelEls.length).toBe(0); - }); - - it("should apply host variant class", () => { - expect(el.classList.contains("tedi-rating--icon")).toBe(true); - }); - }); - - describe("disabled state", () => { - it("should disable buttons via setDisabledState", () => { - component.setDisabledState(true); - fixture.detectChanges(); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - stars.forEach((star) => { - expect((star as HTMLButtonElement).disabled).toBe(true); - }); - }); - - it("should not change value when disabled", () => { - component.setDisabledState(true); - fixture.detectChanges(); - - const onChange = jest.fn(); - component.registerOnChange(onChange); - - component.select(3); - expect(component.value()).toBeNull(); - expect(onChange).not.toHaveBeenCalled(); - }); - - it("should apply disabled host class", () => { - component.setDisabledState(true); - fixture.detectChanges(); - - expect(el.classList.contains("tedi-rating--disabled")).toBe(true); - }); - }); - - describe("roving tabindex", () => { - it("should set tabindex 0 on first item when no selection", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - expect(stars[0].getAttribute("tabindex")).toBe("0"); - expect(stars[1].getAttribute("tabindex")).toBe("-1"); - }); - - it("should set tabindex 0 on selected item", () => { - component.writeValue(3); - fixture.detectChanges(); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - expect(stars[2].getAttribute("tabindex")).toBe("0"); - expect(stars[0].getAttribute("tabindex")).toBe("-1"); - }); - - it("should set tabindex -1 on all items when disabled", () => { - component.setDisabledState(true); - fixture.detectChanges(); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - stars.forEach((star) => { - expect(star.getAttribute("tabindex")).toBe("-1"); - }); - }); - }); - - describe("keyboard navigation", () => { - const dispatchKey = (element: HTMLElement, key: string) => { - element.dispatchEvent( - new KeyboardEvent("keydown", { key, bubbles: true }), - ); - }; - - it("should move focus with ArrowRight", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[0] as HTMLElement).focus(); - dispatchKey(stars[0] as HTMLElement, "ArrowRight"); - expect(document.activeElement).toBe(stars[1]); - }); - - it("should move focus with ArrowLeft", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[2] as HTMLElement).focus(); - dispatchKey(stars[2] as HTMLElement, "ArrowLeft"); - expect(document.activeElement).toBe(stars[1]); - }); - - it("should move focus with ArrowDown", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[0] as HTMLElement).focus(); - dispatchKey(stars[0] as HTMLElement, "ArrowDown"); - expect(document.activeElement).toBe(stars[1]); - }); - - it("should move focus with ArrowUp", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[2] as HTMLElement).focus(); - dispatchKey(stars[2] as HTMLElement, "ArrowUp"); - expect(document.activeElement).toBe(stars[1]); - }); - - it("should wrap from last to first with ArrowRight", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[4] as HTMLElement).focus(); - dispatchKey(stars[4] as HTMLElement, "ArrowRight"); - expect(document.activeElement).toBe(stars[0]); - }); - - it("should wrap from first to last with ArrowLeft", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[0] as HTMLElement).focus(); - dispatchKey(stars[0] as HTMLElement, "ArrowLeft"); - expect(document.activeElement).toBe(stars[4]); - }); - - it("should move to first with Home", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[3] as HTMLElement).focus(); - dispatchKey(stars[3] as HTMLElement, "Home"); - expect(document.activeElement).toBe(stars[0]); - }); - - it("should move to last with End", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[0] as HTMLElement).focus(); - dispatchKey(stars[0] as HTMLElement, "End"); - expect(document.activeElement).toBe(stars[4]); - }); - - it("should select with Enter", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[3] as HTMLElement).focus(); - dispatchKey(stars[3] as HTMLElement, "Enter"); - - expect(component.value()).toBe(4); - expect(onChange).toHaveBeenCalledWith(4); - }); - - it("should select with Space", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[1] as HTMLElement).focus(); - dispatchKey(stars[1] as HTMLElement, " "); - - expect(component.value()).toBe(2); - expect(onChange).toHaveBeenCalledWith(2); - }); - }); - - describe("ControlValueAccessor", () => { - it("should set value via writeValue", () => { - component.writeValue(4); - expect(component.value()).toBe(4); - }); - - it("should handle null writeValue", () => { - component.writeValue(null); - expect(component.value()).toBeNull(); - }); - - it("should call onTouched on blur", () => { - const onTouched = jest.fn(); - component.registerOnTouched(onTouched); - - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[0] as HTMLElement).dispatchEvent( - new Event("blur", { bubbles: true }), - ); - - expect(onTouched).toHaveBeenCalled(); - }); - }); -}); - -@Component({ - standalone: true, - imports: [RatingComponent, ReactiveFormsModule], - template: ``, -}) -class TestHostComponent { - control = new FormControl(null); -} - -describe("RatingComponent with ReactiveFormsModule", () => { - let fixture: ComponentFixture; - let host: TestHostComponent; - let el: HTMLElement; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [TestHostComponent], - }); - - fixture = TestBed.createComponent(TestHostComponent); - host = fixture.componentInstance; - el = fixture.nativeElement; - fixture.detectChanges(); - }); - - it("should sync FormControl value to component", () => { - host.control.setValue(3); - fixture.detectChanges(); - - const fills = Array.from( - el.querySelectorAll(".tedi-rating__star-icon--filled"), - ).map((node) => node.style.width); - expect(fills).toEqual(["100%", "100%", "100%", "0%", "0%"]); - }); - - it("should sync component selection to FormControl", () => { - const stars = el.querySelectorAll(".tedi-rating__segment"); - (stars[3] as HTMLButtonElement).click(); - fixture.detectChanges(); - - expect(host.control.value).toBe(4); - }); - - it("should disable component when FormControl is disabled", () => { - host.control.disable(); - fixture.detectChanges(); - - const ratingEl = el.querySelector("tedi-rating") as HTMLElement; - expect(ratingEl.classList.contains("tedi-rating--disabled")).toBe(true); - }); -}); diff --git a/tedi/components/form/rating/rating.component.ts b/tedi/components/form/rating/rating.component.ts deleted file mode 100644 index a98c93798..000000000 --- a/tedi/components/form/rating/rating.component.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - ElementRef, - forwardRef, - inject, - input, - isDevMode, - model, - signal, - ViewEncapsulation, -} from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconColor, IconComponent } from "../../base/icon/icon.component"; - -export type RatingVariant = "star" | "number" | "icon"; - -export type RatingPrecision = 1 | 0.5; - -export interface RatingItem { - icon: string; - label?: string; -} - -interface StarSegment { - value: number; - leftPercent: number; - widthPercent: number; -} - -interface StarBox { - index: number; - segments: StarSegment[]; -} - -@Component({ - selector: "tedi-rating", - standalone: true, - imports: [IconComponent], - templateUrl: "./rating.component.html", - styleUrl: "./rating.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - host: { - class: "tedi-rating", - "[class.tedi-rating--star]": "variant() === 'star'", - "[class.tedi-rating--number]": "variant() === 'number'", - "[class.tedi-rating--icon]": "variant() === 'icon'", - "[class.tedi-rating--disabled]": "isDisabled()", - role: "radiogroup", - "[attr.aria-label]": "ariaLabel()", - }, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RatingComponent), - multi: true, - }, - ], -}) -export class RatingComponent implements ControlValueAccessor { - readonly value = model(null); - readonly variant = input("star"); - readonly max = input(5); - readonly precision = input(1); - readonly icon = input("kid_star"); - readonly color = input("brand"); - readonly items = input([]); - readonly startLabel = input(); - readonly endLabel = input(); - readonly ariaLabel = input(); - - constructor() { - effect(() => { - if (isDevMode() && this.variant() === "star" && this.precision() === 0.5) { - console.warn( - "[tedi-rating] precision=0.5 makes each half-star target roughly 12px wide, below the WCAG 2.5.8 (24×24px) minimum target size. Prefer precision=1 unless half-star input is essential.", - ); - } - }); - } - - private formDisabled = signal(false); - private hoverValue = signal(null); - private onChange: (value: number | null) => void = () => {}; - private onTouched: () => void = () => {}; - private hostEl = inject(ElementRef); - - readonly isDisabled = computed(() => this.formDisabled()); - - readonly effectivePrecision = computed(() => { - if (this.variant() !== "star") return 1; - return this.precision() === 0.5 ? 0.5 : 1; - }); - - readonly resolvedMax = computed(() => { - if (this.variant() === "icon") { - return this.items().length || this.max(); - } - return this.max(); - }); - - readonly indexedItems = computed(() => - Array.from({ length: this.resolvedMax() }, (_, i) => ({ - index: i + 1, - item: this.items()[i], - })), - ); - - readonly starGrid = computed(() => { - const precision = this.effectivePrecision(); - const subCount = Math.round(1 / precision); - return Array.from({ length: this.resolvedMax() }, (_, i) => { - const index = i + 1; - return { - index, - segments: Array.from({ length: subCount }, (_, k) => ({ - value: this.roundToPrecision(i + (k + 1) * precision), - leftPercent: k * precision * 100, - widthPercent: precision * 100, - })), - }; - }); - }); - - private readonly displayValue = computed( - () => this.hoverValue() ?? this.value(), - ); - - writeValue(value: number | null): void { - this.value.set(value); - } - - registerOnChange(fn: (value: number | null) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState(disabled: boolean): void { - this.formDisabled.set(disabled); - } - - select(value: number): void { - if (this.isDisabled()) return; - const newValue = this.value() === value ? null : value; - this.value.set(newValue); - this.onChange(newValue); - this.onTouched(); - } - - getStarFill(starIndex: number): number { - const display = this.displayValue(); - if (display === null) return 0; - const fill = display - (starIndex - 1); - return Math.max(0, Math.min(1, fill)) * 100; - } - - isSegmentChecked(segmentValue: number): boolean { - const val = this.value(); - return val !== null && segmentValue === this.roundToPrecision(val); - } - - getSegmentTabIndex(segmentValue: number): number { - if (this.isDisabled()) return -1; - const val = this.value(); - if (val !== null) { - return segmentValue === this.roundToPrecision(val) ? 0 : -1; - } - return segmentValue === this.effectivePrecision() ? 0 : -1; - } - - getTabIndex(index: number): number { - if (this.isDisabled()) return -1; - if (this.value() !== null) { - return this.value() === index ? 0 : -1; - } - return index === 1 ? 0 : -1; - } - - setHover(value: number): void { - if (this.isDisabled()) return; - this.hoverValue.set(value); - } - - clearHover(): void { - this.hoverValue.set(null); - } - - handleStarKeydown(event: KeyboardEvent, currentValue: number): void { - const precision = this.effectivePrecision(); - const max = this.resolvedMax(); - let nextValue: number | null = null; - - switch (event.key) { - case "ArrowRight": - case "ArrowDown": - event.preventDefault(); - nextValue = - currentValue < max - ? this.roundToPrecision(currentValue + precision) - : precision; - break; - case "ArrowLeft": - case "ArrowUp": - event.preventDefault(); - nextValue = - currentValue > precision - ? this.roundToPrecision(currentValue - precision) - : max; - break; - case "Home": - event.preventDefault(); - nextValue = precision; - break; - case "End": - event.preventDefault(); - nextValue = max; - break; - case "Enter": - case " ": - event.preventDefault(); - this.select(currentValue); - return; - default: - return; - } - - this.focusSegment(nextValue); - } - - handleKeydown(event: KeyboardEvent, currentIndex: number): void { - const max = this.resolvedMax(); - let nextIndex: number | null = null; - - switch (event.key) { - case "ArrowRight": - case "ArrowDown": - event.preventDefault(); - nextIndex = currentIndex < max ? currentIndex + 1 : 1; - break; - case "ArrowLeft": - case "ArrowUp": - event.preventDefault(); - nextIndex = currentIndex > 1 ? currentIndex - 1 : max; - break; - case "Home": - event.preventDefault(); - nextIndex = 1; - break; - case "End": - event.preventDefault(); - nextIndex = max; - break; - case "Enter": - case " ": - event.preventDefault(); - this.select(currentIndex); - return; - default: - return; - } - - this.focusItem(nextIndex); - } - - handleBlur(): void { - this.onTouched(); - } - - private roundToPrecision(value: number): number { - const precision = this.effectivePrecision(); - const steps = Math.round(value / precision); - return Number((steps * precision).toFixed(10)); - } - - private focusSegment(value: number): void { - this.focusItem(Math.round(value / this.effectivePrecision())); - } - - private focusItem(index: number): void { - const buttons: NodeListOf = - this.hostEl.nativeElement.querySelectorAll('[role="radio"]'); - const button = buttons[index - 1]; - if (button) { - button.focus(); - } - } -} diff --git a/tedi/components/form/rating/rating.stories.ts b/tedi/components/form/rating/rating.stories.ts deleted file mode 100644 index 5c59702db..000000000 --- a/tedi/components/form/rating/rating.stories.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { - Meta, - StoryObj, - argsToTemplate, - moduleMetadata, -} from "@storybook/angular"; -import { NgFor } from "@angular/common"; -import { FormControl, ReactiveFormsModule } from "@angular/forms"; -import { RatingComponent, RatingItem } from "./rating.component"; -import { RowComponent } from "../../helpers/grid/row/row.component"; -import { ColComponent } from "../../helpers/grid/col/col.component"; -import { TextComponent } from "../../base/text/text.component"; -import { AlertComponent } from "../../notifications/alert/alert.component"; - -/** - * Figma ↗ - */ - -export default { - title: "TEDI-Ready/Components/Form/Rating", - component: RatingComponent, - decorators: [ - moduleMetadata({ - imports: [ - RatingComponent, - ReactiveFormsModule, - NgFor, - RowComponent, - ColComponent, - TextComponent, - AlertComponent, - ], - }), - ], - argTypes: { - variant: { - description: "Visual variant of the rating.", - control: "radio", - options: ["star", "number", "icon"], - table: { - category: "inputs", - type: { summary: '"star" | "number" | "icon"' }, - defaultValue: { summary: '"star"' }, - }, - }, - value: { - description: - "Selected rating value (1-based). Supports two-way binding.", - control: "number", - table: { - category: "inputs", - type: { summary: "number | null" }, - defaultValue: { summary: "null" }, - }, - }, - max: { - description: "Maximum number of items for star/number variants.", - control: "number", - table: { - category: "inputs", - type: { summary: "number" }, - defaultValue: { summary: "5" }, - }, - }, - precision: { - description: - "Star-variant increment. Only 1 or 0.5 are supported; 0.5 (half stars) is discouraged — each half-star target falls below the WCAG 2.5.8 24×24px minimum. Ignored by other variants.", - control: "inline-radio", - options: [1, 0.5], - table: { - category: "inputs", - type: { summary: "1 | 0.5" }, - defaultValue: { summary: "1" }, - }, - }, - icon: { - description: - "Material Symbols icon for the star variant. Needs both filled and outlined variants for partial fill to read correctly.", - control: "text", - table: { - category: "inputs", - type: { summary: "string" }, - defaultValue: { summary: '"kid_star"' }, - }, - }, - color: { - description: - "Fill color of selected stars (star variant). Empty stars stay neutral.", - control: "select", - options: [ - "brand", - "brand-dark", - "success", - "warning", - "warning-dark", - "danger", - "primary", - "secondary", - "tertiary", - ], - table: { - category: "inputs", - type: { summary: "IconColor" }, - defaultValue: { summary: '"brand"' }, - }, - }, - items: { - description: - "Array of rating items for icon variant. Each item has an icon name and optional label.", - control: "object", - table: { - category: "inputs", - type: { summary: "RatingItem[]" }, - defaultValue: { summary: "[]" }, - }, - }, - startLabel: { - description: "Label displayed below the first item (number variant).", - control: "text", - table: { - category: "inputs", - type: { summary: "string" }, - }, - }, - endLabel: { - description: "Label displayed below the last item (number variant).", - control: "text", - table: { - category: "inputs", - type: { summary: "string" }, - }, - }, - ariaLabel: { - description: "Accessible label for the rating group.", - control: "text", - table: { - category: "inputs", - type: { summary: "string" }, - }, - }, - }, -} as Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: ``, - }), - args: { - variant: "star", - value: 3, - ariaLabel: "Rate this item", - }, -}; - -export const StarVariant: Story = { - render: () => ({ - template: ` -
-
-

Empty

- -
-
-

Partially filled (3/5)

- -
-
-

Fully filled (5/5)

- -
-
- `, - }), -}; - -export const HalfStar: Story = { - render: () => ({ - template: ` -
-
-

Empty

- -
-
-

2.5 / 5

- -
-
-

3.5 / 5

- -
-
- `, - }), -}; - -export const CustomIcon: Story = { - render: () => ({ - template: ` -
-
-

Hearts

- -
-
-

Thumbs up

- -
-
- `, - }), -}; - -export const NumberVariant: Story = { - render: () => ({ - template: ` -
-
-

With labels

- -
-
-

Without labels

- -
-
- `, - }), -}; - -const iconItems: RatingItem[] = [ - { icon: "sentiment_very_dissatisfied", label: "Väga halb" }, - { icon: "sentiment_dissatisfied", label: "Halb" }, - { icon: "sentiment_neutral", label: "Keskmine" }, - { icon: "sentiment_satisfied", label: "Hea" }, - { icon: "sentiment_very_satisfied", label: "Väga hea" }, -]; - -export const IconVariant: Story = { - render: () => ({ - props: { - items: iconItems, - }, - template: ` - - `, - }), -}; - -export const WithFormControl: Story = { - render: () => ({ - props: { - control: new FormControl(2.5), - }, - template: ` -
- - -
{{ {
-  value: control.value,
-  touched: control.touched,
-  dirty: control.dirty
-} | json }}
-
-
- `, - }), -}; - -export const Disabled: Story = { - render: () => ({ - props: { - disabledControl: new FormControl({ value: 3, disabled: true }), - disabledNumberControl: new FormControl({ value: 5, disabled: true }), - disabledIconControl: new FormControl({ value: 2, disabled: true }), - items: iconItems, - }, - template: ` -
-
-

Star (disabled)

- -
-
-

Number (disabled)

- -
-
-

Icon (disabled)

- -
-
- `, - }), -}; - -export const States: Story = { - parameters: { - pseudo: { - hover: ".pseudo-hover [role='radio']", - active: ".pseudo-active [role='radio']", - focusVisible: ".pseudo-focus [role='radio']", - }, - }, - render: () => ({ - props: { - STATES: ["Default", "Hover", "Active", "Focus", "Selected"], - items: iconItems, - }, - template: ` - -

State

-

Star

-

Number

-

Icon

- - -

{{ state }}

- - - - - - - - - -
-
- `, - }), -}; From 83e17b51b3ee99d351a75a7e8dbff18452a134ea Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 9 Oct 2026 10:55:37 +0300 Subject: [PATCH 4/4] docs(rating): readOnly description improvement #330 --- src/tedi/components/form/rating/rating.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tedi/components/form/rating/rating.component.ts b/src/tedi/components/form/rating/rating.component.ts index f91b6e663..d8c85f015 100644 --- a/src/tedi/components/form/rating/rating.component.ts +++ b/src/tedi/components/form/rating/rating.component.ts @@ -100,7 +100,8 @@ export class RatingComponent implements ControlValueAccessor { */ readonly disabled = input(false, { transform: booleanAttribute }); /** - * Show a compact read-only summary (visual + `{value}/{count}` text) instead of the interactive scale. + * 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 });