diff --git a/src/tedi/components/form/index.ts b/src/tedi/components/form/index.ts index 557690f65..fca0ea079 100644 --- a/src/tedi/components/form/index.ts +++ b/src/tedi/components/form/index.ts @@ -11,6 +11,7 @@ export * from "./date-picker/date-picker.component"; export * from "./feedback-text/feedback-text.component"; export * from "./label-row"; export * from "./number-field/number-field.component"; +export * from "./rating"; export * from "./search"; export * from "./select"; export * from "./slider/slider.component"; diff --git a/src/tedi/components/form/rating/index.ts b/src/tedi/components/form/rating/index.ts new file mode 100644 index 000000000..d6ffafe2f --- /dev/null +++ b/src/tedi/components/form/rating/index.ts @@ -0,0 +1 @@ +export * from "./rating.component"; diff --git a/src/tedi/components/form/rating/rating.component.html b/src/tedi/components/form/rating/rating.component.html new file mode 100644 index 000000000..ca5692c5a --- /dev/null +++ b/src/tedi/components/form/rating/rating.component.html @@ -0,0 +1,114 @@ +@if (readOnly()) { + +} @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..d8c85f015 --- /dev/null +++ b/src/tedi/components/form/rating/rating.component.ts @@ -0,0 +1,336 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + forwardRef, + inject, + input, + model, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { IconComponent, IconSize } from "../../base/icon/icon.component"; +import { + breakpointInput, + BreakpointInput, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { generateUUID } from "../../../helpers/generate-uuid"; + +export type RatingType = "star" | "number" | "icon"; +export type RatingOrientation = "horizontal" | "vertical"; +export type RatingReadOnlyVariant = "summary" | "scale"; + +/** Star glyph size (matches `--feedback-star-size`). */ +const STAR_ICON_SIZE: IconSize = 24; +/** Glyph size inside the circular number/icon items. */ +const CIRCLE_ICON_SIZE: IconSize = 18; + +const DEFAULT_ICONS = [ + "sentiment_very_dissatisfied", + "sentiment_dissatisfied", + "sentiment_neutral", + "sentiment_satisfied", + "sentiment_very_satisfied", +]; + +interface RatingItem { + position: number; + selected: boolean; + previewed: boolean; + icon: string; + accessibleName: string; + caption?: string; +} + +@Component({ + selector: "tedi-rating", + standalone: true, + imports: [IconComponent], + templateUrl: "./rating.component.html", + styleUrl: "./rating.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RatingComponent), + multi: true, + }, + ], + host: { + "[class]": "classes()", + "[attr.role]": "readOnly() ? 'img' : 'radiogroup'", + "[attr.aria-label]": "readOnly() ? label() + ': ' + summary() : label()", + }, +}) +export class RatingComponent implements ControlValueAccessor { + /** Accessible name for the group; also prefixes the `readOnly` summary. */ + readonly label = input.required(); + /** + * Visual style. `star` / `number` fill cumulatively; `icon` highlights a single item. + * @default star + */ + readonly type = input("star"); + /** + * Number of items (the max rating). + * @default 5 (`star` / `icon`), 10 (`number`) + */ + readonly count = input(); + /** + * Selected value (1-based; `0` = none). Supports two-way binding and form controls. + * In `readOnly`, the (possibly fractional) average. + * @default 0 + */ + readonly value = model(0); + /** Per-item labels (length = `count`); used as each item's accessible name and as captions. */ + readonly itemLabels = input(); + /** + * Custom Material Symbol glyph(s): an array (one per item) for `type="icon"`, or a single string to + * swap the `type="star"` glyph. + * @default sentiment faces (`icon`) / `kid_star` (`star`) + */ + readonly icons = input(); + /** + * Disable interaction and mute the colours. + * @default false + */ + readonly disabled = input(false, { transform: booleanAttribute }); + /** + * Show a compact read-only summary (visual + `{value}/{count}` text; text only for `type="number"`) instead + * of the interactive scale. + * @default false + */ + readonly readOnly = input(false, { transform: booleanAttribute }); + /** Number of ratings shown in the `readOnly` summary (e.g. `271 hindajat`); omitted when unset. */ + readonly ratingsCount = input(); + /** + * Show the descriptive raters label next to `ratingsCount` (e.g. `271 hindajat`). Set `false` to + * show the number only (`271`). + * @default true + */ + readonly showRatingsCountLabel = input(true, { transform: booleanAttribute }); + /** + * `readOnly` layout: `summary` (single visual) or `scale` (full star scale with fractional fill). + * @default summary + */ + readonly readOnlyVariant = input("summary"); + /** + * Lay the scale out horizontally or stack it vertically (icon + caption per row). Use `vertical` on + * narrow layouts for `type="icon"` with long `itemLabels`. Accepts a `BreakpointInput`, e.g. + * `{ xs: 'vertical', md: 'horizontal' }`. + * @default horizontal + */ + readonly orientation = input( + { xs: "horizontal" as RatingOrientation }, + { + transform: (v: BreakpointInput) => breakpointInput(v), + }, + ); + /** `name` for the radio inputs. Defaults to a generated id. */ + readonly name = input(); + + protected readonly starIconSize = STAR_ICON_SIZE; + protected readonly circleIconSize = CIRCLE_ICON_SIZE; + + private readonly breakpointService = inject(BreakpointService); + private readonly translationService = inject(TediTranslationService); + private readonly generatedName = `tedi-rating-${generateUUID()}`; + private readonly formDisabled = signal(false); + private readonly hoverValue = signal(null); + private onChange: (value: number) => void = () => {}; + private onTouched: () => void = () => {}; + + readonly isDisabled = computed(() => this.disabled() || this.formDisabled()); + private readonly interactive = computed( + () => !this.isDisabled() && !this.readOnly(), + ); + private readonly isHovering = computed( + () => this.interactive() && this.hoverValue() !== null, + ); + + readonly groupName = computed(() => this.name() ?? this.generatedName); + + readonly total = computed(() => { + const count = this.count(); + const fallback = this.type() === "number" ? 10 : 5; + return count !== undefined && Number.isInteger(count) && count > 0 + ? count + : fallback; + }); + + readonly resolvedOrientation = computed(() => { + const value = this.orientation(); + if ( + value.xxl !== undefined && + this.breakpointService.isAboveBreakpoint("xxl")() + ) + return value.xxl; + if ( + value.xl !== undefined && + this.breakpointService.isAboveBreakpoint("xl")() + ) + return value.xl; + if ( + value.lg !== undefined && + this.breakpointService.isAboveBreakpoint("lg")() + ) + return value.lg; + if ( + value.md !== undefined && + this.breakpointService.isAboveBreakpoint("md")() + ) + return value.md; + if ( + value.sm !== undefined && + this.breakpointService.isAboveBreakpoint("sm")() + ) + return value.sm; + return value.xs; + }); + + private readonly iconList = computed(() => { + const icons = this.icons(); + return Array.isArray(icons) + ? icons + : icons !== undefined + ? [icons] + : undefined; + }); + + readonly starGlyph = computed(() => this.iconList()?.[0] ?? "kid_star"); + + private readonly resolvedIcons = computed( + () => + this.iconList() ?? + (this.total() === DEFAULT_ICONS.length ? DEFAULT_ICONS : undefined), + ); + + readonly items = computed(() => { + const type = this.type(); + const total = this.total(); + const current = this.value(); + const hovering = this.isHovering(); + const hover = this.hoverValue() ?? 0; + const labels = this.itemLabels(); + const icons = this.resolvedIcons(); + + return Array.from({ length: total }, (_, index) => { + const position = index + 1; + const label = labels?.[index]; + const isEndpoint = position === 1 || position === total; + // While hovering, the hovered range previews the pick: items in it take the hover look (selected or + // unselected per the committed value). On cumulative scales items past it read as unselected; the + // single-choice icon scale keeps its current pick visible. + const previewed = hovering && this.isInRange(position, hover); + const selected = + this.isInRange(position, current) && + (!hovering || previewed || type === "icon"); + + return { + position, + selected, + previewed, + icon: icons?.[index] ?? "circle", + // Language-neutral fallback (matches the read-only `value/total` summary format). + accessibleName: label || `${position}/${total}`, + caption: + label && (type === "icon" || (type === "number" && isEndpoint)) + ? label + : undefined, + }; + }); + }); + + readonly hasCaptions = computed( + () => this.type() !== "star" && !!this.itemLabels()?.some(Boolean), + ); + + readonly starCaption = computed(() => { + const labels = this.itemLabels(); + if (this.type() !== "star" || !labels?.some(Boolean)) return undefined; + const display = this.isHovering() ? this.hoverValue()! : this.value(); + return display > 0 ? (labels[display - 1] ?? "") : ""; + }); + + readonly summary = computed(() => { + const locale = this.translationService.getLanguage(); + const formattedValue = new Intl.NumberFormat(locale, { + maximumFractionDigits: 1, + }).format(this.value()); + const ratingsCount = this.ratingsCount(); + const countText = + ratingsCount !== undefined + ? ` - ${ + this.showRatingsCountLabel() + ? this.translationService.translate("rating.raters", ratingsCount) + : new Intl.NumberFormat(locale).format(ratingsCount) + }` + : ""; + return `${formattedValue}/${this.total()}${countText}`; + }); + + readonly readOnlyIcon = computed(() => { + const position = Math.min( + this.total(), + Math.max(1, Math.round(this.value())), + ); + return this.resolvedIcons()?.[position - 1] ?? "circle"; + }); + + readonly partialFills = computed(() => + Array.from( + { length: this.total() }, + (_, index) => Math.max(0, Math.min(1, this.value() - index)) * 100, + ), + ); + + readonly classes = computed(() => { + const classList = ["tedi-rating", `tedi-rating--${this.type()}`]; + if (this.readOnly()) { + classList.push("tedi-rating--readonly"); + } else { + if (this.resolvedOrientation() === "vertical") + classList.push("tedi-rating--vertical"); + if (this.isDisabled()) classList.push("tedi-rating--disabled"); + } + return classList.join(" "); + }); + + writeValue(value: number | null): void { + this.value.set(value ?? 0); + } + + registerOnChange(fn: (value: number) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + } + + select(position: number): void { + if (!this.interactive()) return; + this.value.set(position); + this.onChange(position); + } + + setHover(position: number | null): void { + this.hoverValue.set(this.interactive() ? position : null); + } + + handleBlur(): void { + this.onTouched(); + } + + private isInRange(position: number, target: number): boolean { + return this.type() === "icon" ? position === target : position <= target; + } +} diff --git a/src/tedi/components/form/rating/rating.stories.ts b/src/tedi/components/form/rating/rating.stories.ts new file mode 100644 index 000000000..0edfc3536 --- /dev/null +++ b/src/tedi/components/form/rating/rating.stories.ts @@ -0,0 +1,380 @@ +import { + argsToTemplate, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; +import { FormsModule } from "@angular/forms"; +import { RatingComponent, RatingType } from "./rating.component"; +import { TextComponent } from "../../base/text/text.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { CardComponent } from "../../content/card/card.component"; +import { CardContentComponent } from "../../content/card/card-content/card-content.component"; +import { LabelComponent } from "../../content/label/label.component"; +import { CheckboxComponent } from "../checkbox/checkbox.component"; +import { CheckboxCardComponent } from "../checkbox-card/checkbox-card.component"; +import { CheckboxCardGroupComponent } from "../checkbox-card-group/checkbox-card-group.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; +import { FormFieldComponent } from "../form-field/form-field.component"; +import { TextareaComponent } from "../textarea/textarea.component"; + +/** + * Figma ↗ + */ + +const STAR_LABELS = [ + "Ei jäänud üldse rahule", + "Ei jäänud rahule", + "Osaliselt rahul", + "Jäin rahule", + "Jäin väga rahule", +]; +const ICON_LABELS = ["Väga halb", "Halb", "Keskmine", "Hea", "Väga hea"]; +const NUMBER_LABELS = Array.from({ length: 10 }, (_, index) => + index === 0 ? "Väga halb" : index === 9 ? "Suurepärane" : "", +); + +export default { + title: "TEDI-Ready/Components/Form/Rating", + component: RatingComponent, + decorators: [ + moduleMetadata({ + imports: [ + FormsModule, + TextComponent, + ButtonComponent, + CardComponent, + CardContentComponent, + LabelComponent, + CheckboxComponent, + CheckboxCardComponent, + CheckboxCardGroupComponent, + FeedbackTextComponent, + FormFieldComponent, + TextareaComponent, + ], + }), + ], + parameters: { + status: { type: ["breakpointSupport"] }, + design: { + type: "figma", + url: "https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.76.92?node-id=15548-139124&m=dev", + }, + }, + args: { + label: "Hinnang", + type: "star", + value: 0, + disabled: false, + readOnly: false, + showRatingsCountLabel: true, + readOnlyVariant: "summary", + orientation: "horizontal", + }, + argTypes: { + label: { control: "text" }, + type: { control: "radio", options: ["star", "number", "icon"] }, + count: { + control: { type: "number", min: 1 }, + description: + "Number of items. Defaults to 5 (`star` / `icon`) or 10 (`number`).", + }, + value: { control: { type: "number", min: 0, step: 0.5 } }, + itemLabels: { + control: "object", + description: + "Per-item labels (length = `count`): accessible names and captions.", + }, + icons: { + control: "object", + description: + 'Material Symbol glyph(s): an array for `type="icon"`, a single string for `type="star"`.', + }, + disabled: { control: "boolean" }, + readOnly: { control: "boolean" }, + ratingsCount: { + control: { type: "number", min: 0 }, + description: "Number of ratings shown in the `readOnly` summary.", + }, + showRatingsCountLabel: { control: "boolean" }, + name: { control: "text" }, + readOnlyVariant: { control: "radio", options: ["summary", "scale"] }, + orientation: { + control: "radio", + options: ["horizontal", "vertical"], + description: + "Accepts a `BreakpointInput`, e.g. `{ xs: 'vertical', md: 'horizontal' }`.", + }, + }, + render: (args) => ({ + props: args, + template: ``, + }), +} as Meta; + +type Story = StoryObj; + +const rowLabel = (value: number, max: number) => + value === 0 ? "No rating" : `${value} of ${max}`; + +const scaleShowcase = ( + type: RatingType, + max: number, + itemLabels?: string[], +): Story => ({ + parameters: { controls: { disable: true } }, + render: () => ({ + props: { + rows: Array.from({ length: max + 1 }, (_, value) => ({ + value, + label: rowLabel(value, max), + })), + type, + max, + itemLabels, + }, + // Row label sits on top on mobile (so the scale gets the full width) and moves to a left column from md up. + template: ` +
+ @for (row of rows; track row.value) { +
+

{{ 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"],