Rate a planting with stars in the edit dialog

Overall rating is five clickable stars (real radio buttons underneath, so
the keyboard works) with a Clear button, in place of a select.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Brenda WallaceandClaude Sonnet 5 committed 2026-09-22 17:42:26 +12:00
1 parent 6685e253c5
commit fa5ef3eda6
3 files changed
+96 -7

No files matched your search

+43
View File
@@ -220,3 +220,46 @@ $garden-card-line: #e6e6e6;
}
}
}
// Star rating in the planting edit dialog. The radios are visually hidden, so
// show a ring on the star that has keyboard focus.
.star-rating {
border: 0;
padding: 0;
legend {
float: none;
width: auto;
font-size: inherit;
}
.star-rating-stars {
display: flex;
align-items: center;
min-height: 2.25rem;
}
.star-rating-star {
padding: 0 0.1rem;
color: #9e9e9e;
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
}
.star-rating-star--lit {
color: #f5a623;
}
input:focus-visible + .star-rating-star {
outline: 2px solid var(--bs-link-color, #0a58ca);
outline-offset: 2px;
border-radius: 0.25rem;
}
.star-rating-value {
margin-left: 0.75rem;
color: $garden-card-muted;
font-size: 0.9rem;
}
}
@@ -3,8 +3,7 @@ import React, {useEffect, useState} from 'react';
import {getJson, patchJson} from '../api';
import CropPicker from './CropPicker';
import Modal from './Modal';
const RATINGS = [[1, 'Poor'], [2, ''], [3, ''], [4, ''], [5, 'Great']];
import StarRating from './StarRating';
function humanize(field) {
return field.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase());
@@ -148,11 +147,12 @@ export default function EditPlantingModal({planting, onClose, onSaved}) {
<input id="edit-planting-quantity" type="number" min="1" className="form-control" value={values.quantity ?? ''} onChange={set('quantity')} />
</div>
<div className="col-md-8">
<label className="form-label" htmlFor="edit-planting-rating">Overall rating</label>
<select id="edit-planting-rating" className="form-select" value={values.overall_rating ?? ''} onChange={set('overall_rating')}>
<option value="">Not rated</option>
{RATINGS.map(([number, word]) => <option key={number} value={number}>{word ? `${number} – ${word}` : number}</option>)}
</select>
<StarRating
id="edit-planting-rating"
label="Overall rating"
value={values.overall_rating}
onChange={(rating) => setValues({...values, overall_rating: rating})}
/>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
import React, {useState} from 'react';
const STARS = [1, 2, 3, 4, 5];
const WORDS = {1: 'Poor', 5: 'Great'};
// A rating out of five as stars. They are real radio buttons underneath, so
// the arrow keys and screen readers work as they do for any radio group; the
// stars just light up as far as the one hovered, or chosen. `value` is a number
// from 1 to 5, or null for no rating, and Clear puts it back to that.
export default function StarRating({id, label, value, onChange}) {
const [hovered, setHovered] = useState(null);
const lit = hovered ?? value ?? 0;
return (
<fieldset className="star-rating">
<legend className="form-label">{label}</legend>
<div className="star-rating-stars" onMouseLeave={() => setHovered(null)}>
{STARS.map((number) => (
<React.Fragment key={number}>
<input
type="radio"
className="visually-hidden"
id={`${id}-${number}`}
name={id}
checked={value === number}
onChange={() => onChange(number)}
/>
<label
htmlFor={`${id}-${number}`}
className={`star-rating-star${number <= lit ? ' star-rating-star--lit' : ''}`}
title={WORDS[number] ? `${number} – ${WORDS[number]}` : `${number} of 5`}
onMouseEnter={() => setHovered(number)}
>
<i className={`${number <= lit ? 'fa' : 'far'} fa-star`} aria-hidden="true" />
<span className="visually-hidden">{number} of 5{WORDS[number] ? `, ${WORDS[number]}` : ''}</span>
</label>
</React.Fragment>
))}
<span className="star-rating-value">{value ? `${value} of 5` : 'Not rated'}</span>
{value && (
<button type="button" className="btn btn-sm btn-link" onClick={() => onChange(null)}>Clear</button>
)}
</div>
</fieldset>
);
}