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:
@@ -1,30 +1,9 @@
|
|||||||
<script lang="ts">
|
<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 = {
|
const ratingInWords: Record<number, string> = {
|
||||||
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 = {
|
|
||||||
0: "Not Reviewed",
|
0: "Not Reviewed",
|
||||||
1: "Appalling",
|
1: "Appalling",
|
||||||
2: "Horrible",
|
2: "Horrible",
|
||||||
@@ -37,14 +16,10 @@
|
|||||||
9: "Great",
|
9: "Great",
|
||||||
10: "Masterpiece",
|
10: "Masterpiece",
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeRating = (e: any) => {
|
|
||||||
score = e.target.valueAsNumber * 2
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<StarRatting bind:config on:change={changeRating}/>
|
<StarInput bind:value={score} min={0} max={10} step={1} />
|
||||||
<p>Rating: {config.score * 2}</p>
|
<p>Rating: {score}</p>
|
||||||
<p>{ratingInWords[config.score * 2]}</p>
|
<p>{ratingInWords[score]}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user