fix(frontend): star rating hover via StarInput, retire overlay slider

The vendored overlay-slider Stars.svelte never responded under
Svelte 5 - a runes port plus an explicit value/oninput rewrite both
left it inert, which convicted the mechanism rather than its
syntax. Replace it with StarInput.svelte: per-star hover zones with
fractional math (preserves the full 0-10 granularity),
snapshot-and-revert on mouseleave, commit on click, arrow-key
nudging, disabled mode. It reuses the Star SVG leaf so output is
pixel-identical, stays dumb (value in/out, no stores) for the
planned inline-grid editing, and Rating.svelte keeps owning the
words/score math locally. Legacy on:change forwarding and the
slider CSS go with the old file.
This commit is contained in:
John O'Keefe
2026-09-16 14:00:44 -04:00
parent 8d5d8ac2d7
commit f63563ce16
3 changed files with 140 additions and 94 deletions
+6 -31
View File
@@ -1,30 +1,9 @@
<script lang="ts">
import StarRatting from "../star-rating/Stars.svelte";
import StarInput from "../star-rating/StarInput.svelte";
export let score
let { score = $bindable(0) }: { score: number } = $props();
let config = {
readOnly: false,
countStars: 5,
range: {
min: 0,
max: 5,
step: 0.5
},
score: score / 2,
showScore: false,
name: "rating",
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
starConfig: {
size: 32,
fillColor: '#F9ED4F',
strokeColor: "#e2c714",
unfilledColor: '#FFF',
strokeUnfilledColor: '#000'
}
}
const ratingInWords = {
const ratingInWords: Record<number, string> = {
0: "Not Reviewed",
1: "Appalling",
2: "Horrible",
@@ -37,14 +16,10 @@
9: "Great",
10: "Masterpiece",
}
const changeRating = (e: any) => {
score = e.target.valueAsNumber * 2
}
</script>
<div>
<StarRatting bind:config on:change={changeRating}/>
<p>Rating: {config.score * 2}</p>
<p>{ratingInWords[config.score * 2]}</p>
<StarInput bind:value={score} min={0} max={10} step={1} />
<p>Rating: {score}</p>
<p>{ratingInWords[score]}</p>
</div>
+134
View File
@@ -0,0 +1,134 @@
<!-- Dumb reusable star-rating input. No stores, no backend knowledge:
`value` is written live on hover (reverted on leave unless committed)
and set on click/keyboard; the parent owns what the value means
(words, persistence). Reuses the Star leaf, so output is pixel-identical
to the old overlay-slider version without the overlay. -->
<script lang="ts">
import Star from './components/Star.svelte';
type StarColors = {
size: number;
fillColor: string;
strokeColor: string;
unfilledColor: string;
strokeUnfilledColor: string;
};
let {
value = $bindable(0),
min = 0,
max = 5,
step = 0.5,
count = 5,
disabled = false,
starConfig = {
size: 32,
fillColor: '#F9ED4F',
strokeColor: '#BB8511',
unfilledColor: '#FFF',
strokeUnfilledColor: '#000'
},
onchange,
}: {
value?: number;
min?: number;
max?: number;
step?: number;
count?: number;
disabled?: boolean;
starConfig?: StarColors;
onchange?: (value: number) => void;
} = $props();
// Value at hover entry; restored on leave unless committed. Null = idle.
let baseline: number | null = $state(null);
function clamp(v: number): number {
return Math.min(max, Math.max(min, v));
}
function snap(v: number): number {
const snapped = min + Math.round((v - min) / step) * step;
return clamp(Math.round(snapped * 1e6) / 1e6);
}
// Continuous value for cursor position within star `index`.
function valueAt(index: number, fraction: number): number {
if (index === 0 && fraction < 0.2) return min;
const f = fraction >= 0.5 ? 1 : 0.5;
return snap(min + ((index + f) / count) * (max - min));
}
function fractionOf(e: MouseEvent, el: HTMLElement): number {
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return 1;
return Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
}
function fillOf(index: number): number {
const per = (max - min) / count;
return Math.min(1, Math.max(0, (value - min - index * per) / per));
}
function onHoverMove(e: MouseEvent, el: HTMLElement, index: number) {
if (disabled) return;
if (baseline === null) baseline = value;
value = valueAt(index, fractionOf(e, el));
}
function onHoverLeave() {
if (disabled) return;
if (baseline !== null) {
value = baseline;
baseline = null;
}
}
function commit(v: number) {
if (disabled) return;
value = clamp(v);
baseline = value;
onchange?.(value);
}
</script>
<div
class="stars-container"
role="group"
aria-label="Star rating"
onmouseleave={onHoverLeave}
>
<div class="stars">
{#each Array(count) as _, i}
<button
type="button"
class="flex items-center justify-center p-0 border-0 bg-transparent"
disabled={disabled}
aria-label={`Rate ${i + 1} out of ${count}`}
onmousemove={(e) => onHoverMove(e, e.currentTarget, i)}
onclick={() => commit(value)}
onkeydown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
commit(value + step);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
commit(value - step);
}
}}
>
<Star
id={`star-${i}`}
readOnly={disabled}
starConfig={starConfig}
fillPercentage={fillOf(i)}
/>
</button>
{/each}
</div>
</div>
<style>
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
</style>
-63
View File
@@ -1,63 +0,0 @@
<!-- Originally from @ernane/svelte-star-rating. Wanted to give credit but could not use from the library without causing program crash. -->
<script>
import Star from './components/Star.svelte';
export let config = {
readOnly: false,
countStars: 5,
range: { min: 0, max: 5, step: 0.001 },
score: 0.0,
showScore: true,
name: "stars",
scoreFormat: function(){ return `(${this.score.toFixed(0)}/${this.countStars})` },
starConfig: {
size: 30,
fillColor: '#F9ED4F',
strokeColor: "#BB8511",
unfilledColor: '#FFF',
strokeUnfilledColor: '#000'
}
}
</script>
<section class="stars-container">
<div class="range-stars">
<div class="stars">
{#each Array(config.countStars) as star, id}
{#if Math.floor(config.score) === id}
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={config.score - Math.floor(config.score)}/>
{:else if Math.floor(config.score) > id}
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={1}/>
{:else}
<Star id={config.name + id} readOnly={config.readOnly} starConfig={config.starConfig} fillPercentage={0}/>
{/if}
{/each}
</div>
<input name={config.name}
class="slider"
type="range"
min={config.readOnly ? config.score : config.range.min}
max={config.readOnly ? config.score : config.range.max}
step="{config.range.step}" bind:value={config.score}
on:change
on:click
>
</div>
{#if config.showScore}
<span class="show-score" style="font-size: {config.starConfig.size/2}px;">
{#if config.scoreFormat}
{config.scoreFormat()}
{:else}
({((config.score/config.countStars)*100).toFixed(2)}%)
{/if}
</span>
{/if}
</section>
<style>
.stars-container{ position: relative; display: flex; align-items: center; justify-content: center; gap: .5rem; }
.range-stars{ position: relative; }
.stars{ display: flex; align-items: center; justify-content: center; gap: .5rem; }
.slider{ opacity: 0; cursor: pointer; position: absolute; top: 0; left: 0; right: 0; height: 100%; }
.show-score{ user-select: none; color: #888 }
</style>