Merge pull request #464 from AdoLovesChicken/gradeSliderRange-input

feat(GradeRangeSlider): add typeable min/max inputs
This commit is contained in:
Aden Lindsay
2026-06-27 07:39:33 +09:30
committed by GitHub
2 changed files with 198 additions and 33 deletions
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte";
let { let {
value = $bindable<[number, number]>([0, 100]), value = $bindable<[number, number]>([0, 100]),
min = 0, min = 0,
@@ -13,9 +15,15 @@
let dragging: "min" | "max" | null = $state(null); let dragging: "min" | "max" | null = $state(null);
const span = $derived(max - min || 1); let visual: [number, number] = $state([...value]);
const minPercent = $derived(((value[0] - min) / span) * 100); let animationFrame: number | null = null;
const maxPercent = $derived(((value[1] - min) / span) * 100); onDestroy(() => {
if (animationFrame !== null) cancelAnimationFrame(animationFrame);
});
const span = $derived(Math.max(max - min, 1));
const minPercent = $derived(((visual[0] - min) / span) * 100);
const maxPercent = $derived(((visual[1] - min) / span) * 100);
const minZ = $derived( const minZ = $derived(
dragging === "min" ? 5 : dragging === "max" ? 2 : value[0] > (min + max) / 2 ? 4 : 3, dragging === "min" ? 5 : dragging === "max" ? 2 : value[0] > (min + max) / 2 ? 4 : 3,
@@ -24,23 +32,69 @@
dragging === "max" ? 5 : dragging === "min" ? 2 : value[1] <= (min + max) / 2 ? 4 : 3, dragging === "max" ? 5 : dragging === "min" ? 2 : value[1] <= (min + max) / 2 ? 4 : 3,
); );
function onMinInput(e: Event) { function clamp(n: number) {
const raw = Number((e.currentTarget as HTMLInputElement).value); return Math.min(max, Math.max(min, n));
if (raw > value[1]) {
value = [value[1], raw];
} else {
value = [raw, value[1]];
}
} }
function onMaxInput(e: Event) { function animateVisualTo(target: [number, number]) {
const raw = Number((e.currentTarget as HTMLInputElement).value); if (animationFrame !== null) cancelAnimationFrame(animationFrame);
if (raw < value[0]) {
value = [raw, value[0]]; const start: [number, number] = [...visual];
} else { const startTime = performance.now();
value = [value[0], raw]; const duration = 200;
function frame(now: number) {
// sine wave ease animation
const t = Math.min(1, (now - startTime) / duration);
const eased = Math.sin((t * Math.PI) / 2);
visual = [
start[0] + (target[0] - start[0]) * eased,
start[1] + (target[1] - start[1]) * eased,
];
if (t < 1) {
animationFrame = requestAnimationFrame(frame);
} else {
visual = target;
animationFrame = null;
}
} }
animationFrame = requestAnimationFrame(frame);
} }
function onInput(e: Event, which: "min" | "max", animate: boolean) {
const raw = clamp(Number((e.currentTarget as HTMLInputElement).value));
let next: [number, number];
if (animate) {
next = which === "min"
// if next[0] > next[1]: next[1] = next[0]
? [raw, Math.max(raw, value[1])]
// if next[1] < next[0]: next[0] = next[1]
: [Math.min(raw, value[0]), raw];
} else {
if (which === "min") {
next = raw > value[1]
? [value[1], raw]
: [raw, value[1]];
} else {
next = raw < value[0]
? [raw, value[0]]
: [value[0], raw];
}
}
value = next;
if (animate) {
animateVisualTo(next);
} else {
visual = next;
}
}
</script> </script>
<div class="bsplus-grade-range-slider"> <div class="bsplus-grade-range-slider">
@@ -59,8 +113,8 @@
{min} {min}
{max} {max}
{step} {step}
value={value[0]} value={visual[0]}
oninput={onMinInput} oninput={(e) => onInput(e, "min", false)}
onpointerdown={() => (dragging = "min")} onpointerdown={() => (dragging = "min")}
onpointerup={() => (dragging = null)} onpointerup={() => (dragging = null)}
onpointercancel={() => (dragging = null)} onpointercancel={() => (dragging = null)}
@@ -79,8 +133,8 @@
{min} {min}
{max} {max}
{step} {step}
value={value[1]} value={visual[1]}
oninput={onMaxInput} oninput={(e) => onInput(e, "max", false)}
onpointerdown={() => (dragging = "max")} onpointerdown={() => (dragging = "max")}
onpointerup={() => (dragging = null)} onpointerup={() => (dragging = null)}
onpointercancel={() => (dragging = null)} onpointercancel={() => (dragging = null)}
@@ -94,15 +148,42 @@
aria-valuenow={value[1]} aria-valuenow={value[1]}
/> />
</div> </div>
<span class="bsplus-analytics-range-value" aria-live="polite">
{value[0]}% {value[1]}% <div class="bsplus-analytics-range-display" aria-live="polite">
</span> <span class="bsplus-analytics-range-input-wrap">
<input
type="number"
class="bsplus-analytics-range-value"
value={value[0]}
oninput={(e) => onInput(e, "min", true)}
placeholder={min}
min={min}
max={max}
step={step}
/>
<span class="bsplus-analytics-range-suffix">%</span>
</span>
<span class="bsplus-analytics-range-dash"></span>
<span class="bsplus-analytics-range-input-wrap">
<input
type="number"
class="bsplus-analytics-range-value"
value={value[1]}
oninput={(e) => onInput(e, "max", true)}
placeholder={max}
min={min}
max={max}
step={step}
/>
<span class="bsplus-analytics-range-suffix">%</span>
</span>
</div>
</div> </div>
<style> <style>
.bsplus-grade-range-slider { .bsplus-grade-range-slider {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.65rem; gap: 0.65rem;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -110,12 +191,44 @@
.bsplus-grade-range-slider-track-wrap { .bsplus-grade-range-slider-track-wrap {
position: relative; position: relative;
flex: 1; width: 100%;
height: 1.5rem; height: 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.bsplus-analytics-range-display {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-shrink: 0;
white-space: nowrap;
}
.bsplus-analytics-range-input-wrap {
position: relative;
display: inline-block;
}
.bsplus-analytics-range-suffix {
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
color: var(--bsplus-analytics-muted);
font-size: 0.75rem;
font-weight: 500;
pointer-events: none;
opacity: 0.6;
}
.bsplus-analytics-range-dash {
color: var(--bsplus-analytics-muted);
font-weight: 700;
padding: 0 0.15rem;
}
.bsplus-grade-range-slider-track { .bsplus-grade-range-slider-track {
position: absolute; position: absolute;
left: 0; left: 0;
+60 -8
View File
@@ -421,8 +421,10 @@
} }
.bsplus-analytics-filters .bsplus-analytics-range-value { .bsplus-analytics-filters .bsplus-analytics-range-value {
min-width: 3.75rem; background-color: var(--bsplus-analytics-control-bg-elevated);
text-align: right; border-color: var(--bsplus-analytics-control-border-strong);
box-shadow: 0 1px 4px
color-mix(in srgb, var(--bsplus-analytics-text) 10%, transparent);
} }
@media (min-width: 900px) { @media (min-width: 900px) {
@@ -702,12 +704,62 @@
} }
.bsplus-analytics-range-value { .bsplus-analytics-range-value {
font-size: 0.75rem; appearance: none;
font-weight: 600; font-family: inherit;
color: var(--bsplus-analytics-muted); font-size: 0.875rem;
white-space: nowrap; font-weight: 500;
min-width: 4.5rem; color: var(--bsplus-analytics-text);
text-align: right; background-color: var(--bsplus-analytics-control-bg);
border: 2px solid var(--bsplus-analytics-control-border);
border-radius: var(--bsplus-analytics-radius-sm);
padding: 0.5rem 1.8rem 0.5rem 0.5rem;
min-height: 2.75rem;
min-width: 3.5rem;
width: auto;
max-width: 4.5rem;
text-align: center;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease,
transform 0.2s var(--bsplus-analytics-ease);
box-shadow: 0 1px 3px color-mix(in srgb, var(--bsplus-analytics-text) 8%, transparent);
}
.bsplus-analytics-range-value:hover {
background-color: color-mix(
in srgb,
var(--bsplus-analytics-surface) 96%,
var(--bsplus-analytics-surface-2) 4%
);
border-color: color-mix(
in srgb,
var(--bsplus-analytics-accent) 35%,
var(--bsplus-analytics-control-border)
);
}
.bsplus-analytics-range-value:focus {
outline: none;
background-color: color-mix(
in srgb,
var(--bsplus-analytics-surface) 96%,
var(--bsplus-analytics-surface-2) 4%
);
border-color: var(--bsplus-analytics-accent);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--bsplus-analytics-text) 12%, transparent),
0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
}
.bsplus-analytics-range-value::-webkit-outer-spin-button,
.bsplus-analytics-range-value::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.bsplus-analytics-range-value[type="number"] {
-moz-appearance: textfield;
} }
/* Custom dropdowns (time period, subjects) */ /* Custom dropdowns (time period, subjects) */