Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9a966b8
fix(Rating): grey out a disabled rating
Lisa18289 Aug 20, 2026
5e073b0
feat(Rating): individual segments, segment count and single fill
Lisa18289 Aug 20, 2026
ad24d25
Merge remote-tracking branch 'origin/main' into claude/rating-compone…
Lisa18289 Aug 24, 2026
25ed438
test: update visual regression screenshots
github-actions[bot] Aug 20, 2026
2f17d65
test(Rating): drop the stale webkit-linux states baseline
Lisa18289 Aug 24, 2026
f4f32bc
docs(Rating): fit the scale best practice to a configurable segment c…
Lisa18289 Aug 24, 2026
cbbf72c
Merge branch 'main' into claude/rating-component-improvements-7abafc
Lisa18289 Aug 25, 2026
c09ce19
fix(nx): run the scss-types generator before test:compile
Lisa18289 Aug 25, 2026
00f337c
Merge remote-tracking branch 'origin/claude/rating-component-improvem…
Lisa18289 Aug 25, 2026
2972140
test(Rating): move the version gate to the next unreleased alpha
Lisa18289 Aug 25, 2026
eae2882
test: update visual regression screenshots
github-actions[bot] Aug 25, 2026
8c32a15
fix(Rating): keep the selected value in local state for remote apps
Lisa18289 Aug 25, 2026
74bb613
Merge remote-tracking branch 'origin/claude/rating-component-improvem…
Lisa18289 Aug 25, 2026
ae09d65
refactor(Rating): index the modifier classes directly again
Lisa18289 Aug 25, 2026
c6ab44d
refactor(remote-dom-demo): move the rating form example to the form page
Lisa18289 Aug 25, 2026
c4c1913
test(Rating): guard that an externally set value wins
Lisa18289 Aug 25, 2026
50b310b
demo(react-hook-form): let the Set value button move the rating too
Lisa18289 Aug 25, 2026
476a02b
feat(Rating)!: report the selected value as a number
Lisa18289 Aug 25, 2026
4756748
test(Rating): show a real satisfaction scale in the visual test
Lisa18289 Aug 25, 2026
d33c032
test: update visual regression screenshots
github-actions[bot] Aug 25, 2026
ad4584d
Merge remote-tracking branch 'origin/next' into claude/rating-compone…
Lisa18289 Aug 26, 2026
934eaf3
feat(Rating): report the selected value as a number via onValueChange
Lisa18289 Aug 26, 2026
99bbf30
Merge remote-tracking branch 'origin/claude/rating-component-improvem…
Lisa18289 Aug 26, 2026
8ed0cca
Merge remote-tracking branch 'origin/next' into claude/rating-compone…
Lisa18289 Aug 27, 2026
f4e17c4
Revert "feat(Rating): report the selected value as a number via onVal…
Lisa18289 Aug 27, 2026
abb83bd
Merge remote-tracking branch 'origin/next' into claude/rating-compone…
Lisa18289 Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions AGENTS.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {
Label,
Rating,
} from "@mittwald/flow-react-components";

<Rating maxValue={10} defaultValue={7}>
<Label>Bewertung</Label>
</Rating>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
Icon,
Label,
Rating,
RatingSegment,
} from "@mittwald/flow-react-components";
import {
IconMoodEmpty,
IconMoodEmptyFilled,
IconMoodHappy,
IconMoodHappyFilled,
IconMoodSad,
IconMoodSadFilled,
} from "@tabler/icons-react";

<Rating fill="single" defaultValue={3}>
<Label>Wie zufrieden bist du?</Label>
<RatingSegment
aria-label="Unzufrieden"
iconEmpty={
<Icon>
<IconMoodSad />
</Icon>
}
iconFilled={
<Icon color="danger">
<IconMoodSadFilled />
</Icon>
}
/>
<RatingSegment
aria-label="Neutral"
iconEmpty={
<Icon>
<IconMoodEmpty />
</Icon>
}
iconFilled={
<Icon color="warning">
<IconMoodEmptyFilled />
</Icon>
}
/>
<RatingSegment
aria-label="Zufrieden"
iconEmpty={
<Icon>
<IconMoodHappy />
</Icon>
}
iconFilled={
<Icon color="success">
<IconMoodHappyFilled />
</Icon>
}
/>
</Rating>;
35 changes: 31 additions & 4 deletions apps/docs/src/content/04-components/form-controls/rating/index.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
component: Rating
description:
Die Rating Component ermöglicht die Eingabe und Darstellung einer Bewertung
von 0 bis 5 Sternen.
Die Rating Component ermöglicht die Eingabe und Darstellung einer Bewertung
standardmäßig mit fünf Sternen.
---

<LiveCodeEditor stretch />
Expand All @@ -13,8 +13,8 @@ description:

- Unterstütze die visuelle Darstellung immer mit ergänzendem Kontext. Dazu
eignet sich zum Beispiel ein [Label](/04-components/content/label).
- Nutze eine konsistente Skalierung von 0 bis 5 Sternen innerhalb eines
Interfaces.
- Nutze innerhalb eines Interfaces eine konsistente Skalierung. Gleichartige
Bewertungen erhalten dieselbe Anzahl an Segmenten und dieselben Symbole.

---

Expand All @@ -26,6 +26,15 @@ Es stehen die Größen **Medium** oder **Small** zur Verfügung.

---

# Anzahl der Segmente

Über `maxValue` wird die Anzahl der Segmente gesetzt. Ohne Angabe besteht ein
Rating aus fünf Segmenten.

<LiveCodeEditor example="max-value" editorCollapsed />

---

# Readonly

Verwende `isReadOnly`, wenn du die Rating-Component nur zur Darstellung
Expand All @@ -44,6 +53,24 @@ verwenden möchtest.

---

# Icons pro Segment

Soll jede Stufe ihr eigenes Symbol haben, werden die Segmente einzeln über
`<RatingSegment />` in das Rating gelegt. Jedes Segment unterstützt eigene
`iconEmpty`- und `iconFilled`-Icons und überschreibt damit die Icons des
Ratings. Sind Segmente angegeben, bestimmen sie die Anzahl der Segmente.

Über `aria-label` lassen sich die automatischen Labels („3 von 5") pro Segment
überschreiben.

Über `fill` wird zusätzlich gesteuert, welche Segmente als gefüllt dargestellt
werden: **Cumulative** füllt alle Segmente bis zum Wert (Standard, das Verhalten
einer Sternebewertung), **Single** nur das gewählte Segment.

<LiveCodeEditor example="segments" editorCollapsed />

---

# Kombiniere mit ...

## BigNumber
Expand Down
1 change: 1 addition & 0 deletions apps/remote-dom-demo/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default function Layout(props: PropsWithChildren) {
Notifications
</NavigationItem>
<NavigationItem page="popover">Popover</NavigationItem>
<NavigationItem page="rating">Rating</NavigationItem>
</NavigationGroup>
<TunnelExit id="remote-demo" />
</Navigation>
Expand Down
72 changes: 72 additions & 0 deletions apps/remote-dom-demo/src/app/remote/rating/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";
import {
Flex,
Heading,
Icon,
Label,
Rating,
RatingSegment,
} from "@mittwald/flow-remote-react-components";
import {
IconMoodEmpty,
IconMoodEmptyFilled,
IconMoodHappy,
IconMoodHappyFilled,
IconMoodSad,
IconMoodSadFilled,
} from "@tabler/icons-react";

export default function Page() {
return (
<Flex direction="column" gap="l">
<Heading>Default</Heading>
<Rating defaultValue={2}>
<Label>Cantina rating</Label>
</Rating>

<Heading>Segments with single fill</Heading>
<Rating fill="single" defaultValue={2}>
<Label>How was your meal at the cantina?</Label>
<RatingSegment
aria-label="Terrible"
iconEmpty={
<Icon>
<IconMoodSad />
</Icon>
}
iconFilled={
<Icon color="danger">
<IconMoodSadFilled />
</Icon>
}
/>
<RatingSegment
aria-label="Okay"
iconEmpty={
<Icon>
<IconMoodEmpty />
</Icon>
}
iconFilled={
<Icon color="warning">
<IconMoodEmptyFilled />
</Icon>
}
/>
<RatingSegment
aria-label="Great"
iconEmpty={
<Icon>
<IconMoodHappy />
</Icon>
}
iconFilled={
<Icon color="success">
<IconMoodHappyFilled />
</Icon>
}
/>
</Rating>
</Flex>
);
}
8 changes: 8 additions & 0 deletions apps/remote-dom-demo/src/app/remote/react-hook-form/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
FileField,
Label,
Option,
Rating,
MarkdownEditor,
Section,
Select,
Expand Down Expand Up @@ -52,6 +53,7 @@ export default function Page() {
defaultValues: {
name: "",
dateRange: [],
cantina: 3,
account: "Rogue Squadron",
confirm: false,
email: "",
Expand Down Expand Up @@ -103,6 +105,11 @@ export default function Page() {
<Label>Date range</Label>
</DateRangePicker>
</Field>
<Field name="cantina">
<Rating>
<Label>Cantina rating</Label>
</Rating>
</Field>
<TranslationProvider
translations={{
"en-US": {
Expand Down Expand Up @@ -196,6 +203,7 @@ export default function Page() {
<Button
onPress={() => {
form.setValue("email", "leia@rebel-alliance.com");
form.setValue("cantina", 5);
}}
>
Set value
Expand Down
2 changes: 1 addition & 1 deletion nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"cache": true
},
"test:compile": {
"dependsOn": ["^build", "build:icons"],
"dependsOn": ["^build", "build:icons", "build:scss-types"],
"cache": true
},
"test:cross-version:prepare": {
Expand Down
128 changes: 128 additions & 0 deletions packages/components/src/components/Rating/Rating.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import { useForm } from "react-hook-form";
import { Rating } from "@/components/Rating";
import { Button } from "@/components/Button";
import { Label } from "@/components/Label";
import { Form, typedField } from "@/integrations/react-hook-form";
import { RatingSegment } from "@/components/Rating/components/RatingSegment";
import { IconStarFilled } from "@/components/Icon/components/icons";
import styles from "./Rating.module.scss";

const segments = (): HTMLElement[] =>
Array.from(document.querySelectorAll(`.${styles.ratingSegment}`));

// Both icons are stacked and cross-faded, so filled is an opacity, not a node.
const fillStates = (): boolean[] =>
segments().map((segment) => {
const filled = segment.querySelector(`.${styles.filled}`);
return !!filled && getComputedStyle(filled).opacity === "1";
});

test("segment children set the number of segments and their labels", async () => {
await render(
<Rating aria-label="Rating" maxValue={10} value={2}>
<RatingSegment />
<RatingSegment />
<RatingSegment />
</Rating>,
);

expect(segments()).toHaveLength(3);
await expect
.element(page.getByRole("radio", { name: "2 of 3" }))
.toBeInTheDocument();
});

test("a segment label can be overridden", async () => {
await render(
<Rating aria-label="Rating">
<RatingSegment aria-label="Terrible" />
<RatingSegment aria-label="Great" />
</Rating>,
);

await expect
.element(page.getByRole("radio", { name: "Terrible" }))
.toBeInTheDocument();
});

test("single fill previews the hovered segment", async () => {
await render(<Rating aria-label="Rating" fill="single" value={2} />);

// Hover the label, not the radio input — the stacked icons cover the input.
await page.getByLocator("label:has(input[value='4'])").hover();

await expect.poll(fillStates).toEqual([false, false, false, true, false]);
});

test("a segment icon wins over the rating icon", async () => {
await render(
<Rating
aria-label="Rating"
value={2}
iconFilled={<IconStarFilled data-testid="rating-icon" />}
>
<RatingSegment />
<RatingSegment
iconFilled={<IconStarFilled data-testid="segment-icon" />}
/>
</Rating>,
);

const [first, second] = segments();

expect(first?.querySelector("[data-testid='rating-icon']")).not.toBeNull();
expect(second?.querySelector("[data-testid='segment-icon']")).not.toBeNull();
});

test("the rating's size reaches the icons of a segment", async () => {
await render(
<Rating aria-label="Rating" value={1} size="s">
<RatingSegment />
</Rating>,
);

const [segment] = segments();
const width = segment
?.querySelector(`.${styles.filled}`)
?.getBoundingClientRect().width;

expect(width).toBeLessThan(24);
});

/*
* The rating keeps the selected value in local state, so a value set from the
* outside has to win over it.
*/
test("a value set on the form reaches the rating", async () => {
const Fixture = () => {
const form = useForm<{ rating: number }>({ defaultValues: { rating: 2 } });
const Field = typedField(form);

return (
<Form form={form} onSubmit={async () => undefined}>
<Field name="rating">
<Rating>
<Label>Rating</Label>
</Rating>
</Field>
<Button onPress={() => form.setValue("rating", 4)}>Set to 4</Button>
</Form>
);
};

await render(<Fixture />);

const selectedValue = () =>
Array.from(
document.querySelectorAll<HTMLInputElement>("input[type=radio]"),
).find((input) => input.checked)?.value;

expect(selectedValue()).toBe("2");

await page.getByRole("button", { name: "Set to 4" }).click();

await expect.poll(selectedValue).toBe("4");
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ declare const classNames: {
readonly ratingSegment: "ratingSegment";
readonly empty: "empty";
readonly filled: "filled";
readonly "fill-cumulative": "fill-cumulative";
readonly current: "current";
readonly star: "star";
readonly starFilled: "starFilled";
readonly "fill-single": "fill-single";
readonly "size-s": "size-s";
readonly "size-m": "size-m";
};
Expand Down
Loading
Loading