PlRating
A score out of five, as a row of stars. Underneath an interactive one is a real radio group, one tab stop, arrow keys, and a value in a form submission.
import { PlRating } from 'plass-ui';
<PlRating value={score} onValueChange={setScore} />;import 'package:plass_ui/plass_ui.dart';
PlRating(
value: score,
onChanged: (double next) => setState(() => score = next),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The score. Use with onValueChange for a controlled rating |
| defaultValue | number | 0 | Where an uncontrolled rating starts |
| onValueChange | (value: number) => void | — | Called with the new score. 0 is what a cleared rating reports |
| count | number | 5 | How many stars there are, and therefore the highest score |
| precision | number | 1 | The smallest step that can be chosen — 0.5 gives half stars. It never bounds what is drawn |
| icon · emptyIcon | ReactNode | — | The glyphs a filled and an empty star are drawn with. They have to be the same shape |
| clearable | boolean | true | Choosing the score that is already chosen clears it back to 0 |
| readOnly | boolean | false | Shows the score without letting it be changed. The inputs go and it becomes one image |
| disabled | boolean | false | Unavailable. The light goes out of the whole row |
| name | string | — | Identifies the value when a form is submitted |
| required | boolean | false | A form will not submit until a star has been chosen |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height of one star, on the standalone-glyph ladder |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'warning' | Semantic colour role. warning by default, because that is the amber a star is expected to be |
| label | string | 'Rating' | Names the whole control |
| valueLabel | (value: number, count: number) => string | `{value} out of {count}` | What one choice, and the whole control once it is read only, is called |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | double | — | The score. 0 is no rating at all. Controlled: there is no defaultValue |
| onChanged | ValueChanged<double>? | — | Called with the new score. Leaving it out freezes the rating where it is |
| count | int | 5 | How many stars there are, and therefore the highest score |
| precision | double | 1 | The smallest step that can be chosen — 0.5 gives half stars. It never bounds what is drawn |
| icon · emptyIcon | Widget? | — | The glyphs a filled and an empty star are drawn with. They have to be the same shape |
| clearable | bool | true | Choosing the score that is already chosen clears it back to 0 |
| readOnly | bool | false | Shows the score without letting it be changed. The inputs go and it becomes one image |
| disabled | bool | false | Unavailable. The light goes out of the whole row |
| sizeshared | PlassSize | PlassSize.md | Height of one star, on the standalone-glyph ladder |
| colorshared | PlassColor | PlassColor.warning | Semantic colour role. warning by default, because that is the amber a star is expected to be |
| label | String | 'Rating' | Names the whole control |
| valueLabel | PlRatingValueLabel | PlRating.defaultValueLabel | What one choice, and the whole control once it is read only, is called |
| focusNode · autofocus | FocusNode? · bool | — | Drive focus from outside, or take it on insertion |
Every native <div> attribute passes straight through. color is excluded because it collides with the color in the table above, and onChange because the row spells it onValueChange.
The rating is controlled: it is handed a value and reports the one that should replace it. There is no defaultValue anywhere in this package, because that is how Flutter's own controls work.
There is no variant and no elevation: a star is a mark on the page, not a surface. What the shared axes mean across the library is in prop conventions.
Examples
precision
The smallest step that can be chosen, as a fraction of one star. 0.5 gives half stars, 1 whole ones.
It bounds what a reader can pick and nothing else. A value of 4.3 is drawn as four stars and a third at every precision, because an average is not a choice. Rounding it to the nearest half would be reporting a different number from the one the component was handed.
import { PlRating, PlTypography } from 'plass-ui';
export default function RatingPrecision() {
return (
<div className="flex flex-col gap-3">
{[1, 0.5, 0.25].map((precision) => (
<div key={precision} className="flex items-center gap-3">
<PlRating precision={precision} defaultValue={3} />
<PlTypography level="caption">precision={precision}</PlTypography>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class RatingPrecision extends StatefulWidget {
const RatingPrecision({super.key});
@override
State<RatingPrecision> createState() => _RatingPrecisionState();
}
class _RatingPrecisionState extends State<RatingPrecision> {
final Map<double, double> _scores = <double, double>{1: 3, 0.5: 3, 0.25: 3};
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: <Widget>[
for (final double precision in _scores.keys)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
PlRating(
value: _scores[precision]!,
precision: precision,
onChanged: (double next) => setState(() => _scores[precision] = next),
),
PlTypography('precision: $precision', level: PlTypographyLevel.caption),
],
),
],
);
}
}readOnly
A product's average, or a rating somebody else left.
It is a different component in the same clothes: no inputs, no radio group, and one role="img"image semantics node carrying the score as a sentence. A star display that kept twenty focusable radios would be twenty tab stops on a page that was only reporting a number.
This is also the one readOnly in the library that does not drain the saturation. It is not a control being held still, there are no controls left, and a row of grey stars would say the score itself was unavailable.
import { PlRating, PlTypography } from 'plass-ui';
export default function RatingAverage() {
return (
<div className="flex flex-col gap-3">
{[4.3, 2.5, 0].map((score) => (
<div key={score} className="flex items-center gap-3">
<PlRating readOnly value={score} />
<PlTypography level="caption">value={score}</PlTypography>
</div>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class RatingAverage extends StatelessWidget {
const RatingAverage({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: <Widget>[
for (final double score in <double>[4.3, 2.5, 0])
Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
PlRating(value: score, readOnly: true),
PlTypography('value: $score', level: PlTypographyLevel.caption),
],
),
],
);
}
}The fraction
The filled star is laid over the empty one and clipped to a percentage of the width. Nothing is transformed and no glyph is scaled, so a half star is the left half of exactly the star beside it, which is the house no-transform rule holding on a component whose whole job is a partial shape.
The clip runs from the inline start, so it fills from the right under RTL without anything being told to.
icon and emptyIcon
Both, or neither. The two drawings are laid one over the other and the top one is cropped, so a filled heart over an outlined star would show as a rim that does not line up with what is inside it.
import { PlRating } from 'plass-ui';
const HeartFilled = () => (
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M8 13.7 1.9 8.1a3.4 3.4 0 0 1 4.8-4.8L8 4.6l1.3-1.3a3.4 3.4 0 1 1 4.8 4.8Z" />
</svg>
);
const HeartOutline = () => (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M8 13.7 1.9 8.1a3.4 3.4 0 0 1 4.8-4.8L8 4.6l1.3-1.3a3.4 3.4 0 1 1 4.8 4.8Z" />
</svg>
);
export default function RatingIcons() {
return (
<PlRating color="danger" defaultValue={3} icon={<HeartFilled />} emptyIcon={<HeartOutline />} />
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
import 'package:plass_ui_example/demos/glyphs.dart';
class RatingIcons extends StatefulWidget {
const RatingIcons({super.key});
@override
State<RatingIcons> createState() => _RatingIconsState();
}
class _RatingIconsState extends State<RatingIcons> {
double _score = 3;
@override
Widget build(BuildContext context) {
return PlRating(
value: _score,
color: PlassColor.danger,
// The same drawing twice — the two are laid one over the other and the
// top one is cropped, so a filled heart over an outlined star would show
// as a rim that does not line up with what is inside it.
icon: const HeartGlyph(),
emptyIcon: const HeartGlyph(),
onChanged: (double next) => setState(() => _score = next),
);
}
}size
The standalone-glyph ladder, the same one PlIcon uses, because a star is content rather than a control. It is measured against the text it sits beside, not against the row it sits in.
import { PlRating } from 'plass-ui';
export default function RatingSizes() {
return (
<div className="flex flex-col items-start gap-3">
{(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((size) => (
<PlRating key={size} size={size} defaultValue={4} />
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class RatingSizes extends StatelessWidget {
const RatingSizes({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: <Widget>[
for (final PlassSize size in PlassSize.values)
PlRating(value: 4, size: size, readOnly: true),
],
);
}
}color
warning by default, the amber a star is expected to be, rather than the primary everything else takes. It is the one place in the library where a component's default colour is chosen by what the object is instead of by what it means.
import { PlRating } from 'plass-ui';
export default function RatingColors() {
return (
<div className="flex flex-col items-start gap-3">
{(['warning', 'primary', 'danger', 'success'] as const).map((color) => (
<PlRating key={color} color={color} defaultValue={4} />
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class RatingColors extends StatelessWidget {
const RatingColors({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: <Widget>[
for (final PlassColor color in <PlassColor>[
PlassColor.warning,
PlassColor.primary,
PlassColor.danger,
PlassColor.success,
])
PlRating(value: 4, color: color, readOnly: true),
],
);
}
}clearable and disabled
Choosing the score that is already chosen clears it back to 0, which is the only way to take a rating back once one has been left. Turn it off where a score is required.
disabled is the house treatment: the light goes out of the row, the page shows through it, and the family stays. A grey row would be a second vocabulary for the same state.
import { PlRating, PlTypography } from 'plass-ui';
export default function RatingStates() {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<PlRating defaultValue={3} />
<PlTypography level="caption">interactive</PlTypography>
</div>
<div className="flex items-center gap-3">
<PlRating readOnly value={3.5} />
<PlTypography level="caption">readOnly</PlTypography>
</div>
<div className="flex items-center gap-3">
<PlRating disabled value={3} />
<PlTypography level="caption">disabled</PlTypography>
</div>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class RatingStates extends StatefulWidget {
const RatingStates({super.key});
@override
State<RatingStates> createState() => _RatingStatesState();
}
class _RatingStatesState extends State<RatingStates> {
double _score = 3;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
PlRating(value: _score, onChanged: (double next) => setState(() => _score = next)),
const PlTypography('interactive', level: PlTypographyLevel.caption),
],
),
const Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
PlRating(value: 3.5, readOnly: true),
PlTypography('readOnly', level: PlTypographyLevel.caption),
],
),
const Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
PlRating(value: 3, disabled: true),
PlTypography('disabled', level: PlTypographyLevel.caption),
],
),
],
);
}
}Accessibility
- An interactive rating is a radio group, because a score is exactly one of these. One tab stop for the row, arrow keys within it, the chosen score marked, and a value in a form submission, none of which a row of buttons would have.
- Every choice is named by the score it stands for (
3 out of 5).valueLabelis where another language sets its own; nothing here is ever drawn. - A read-only rating drops the radios entirely and becomes one image with the score as its name.
- The glyphs are decorative. What is announced is the sentence, not the drawing.
- The inputs are real
<input type="radio">s in a visually hidden box, one under each fraction of a star.namesubmits with the form;requiredblocks the submit until a star is chosen. - Clearing rides on
clickrather than onchange: clicking a radio that is already checked fires a click and no change at all, and that click is exactly the gesture being listened for.
- Every choosable fraction is its own semantics node, marked as one of a mutually exclusive set and carrying the score it stands for. A screen reader can activate one directly.
- The row is one focus stop and the arrow keys move the score by one
precisionstep, which is what a radio group gives the React build for free. Home clears it and End takes it to the top. The arrows follow the writing direction, so they run the other way under RTL. - The shortcuts are declared on the row rather than inherited, so the widget behaves the same in a bare
WidgetsAppor with no app widget above it at all.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
value / defaultValue / onValueChange | value / onChanged | Flutter's own controls are controlled, and its name for the callback. |
a radio group of hidden <input>s | semantics nodes and one focus stop | There is no form to submit into and no native radio to inherit a keyboard from, so the arrows are bound on the row. |
name, required | — | Both are about an HTML form submission, which Flutter has no equivalent of. |
| a focus ring per star | one ring round the row | The row is one focus node here, so the ring is round what actually holds focus. |
className, style | — | There is no class list and no style attribute to pass through. |