From 7c8577ba3260bc4e0836dd1e162ca103fc60f947 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 1 Jun 2026 14:08:58 -0600 Subject: [PATCH] feat(ui): redesign range input with dynamic progress styling and improved accessibility Revamp the range input component to support dynamic progress indication using CSS custom properties and linear gradients. Add logic to compute and set the progress percentage based on current value, min, and max. Refine focus and disabled states for better accessibility and usability. Update styling for both WebKit and Mozilla engines to ensure consistent appearance. This change enhances visual feedback and modernizes the range slider UI. --- src/components/ui/range.tsx | 58 ++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/components/ui/range.tsx b/src/components/ui/range.tsx index a2f6f25..efe0c54 100644 --- a/src/components/ui/range.tsx +++ b/src/components/ui/range.tsx @@ -1,12 +1,50 @@ -import type { InputHTMLAttributes } from 'react'; +import type { CSSProperties, InputHTMLAttributes } from 'react'; import { cn } from './cn'; +type RangeStyle = CSSProperties & { + '--range-progress'?: string; +}; + +function toNumber(value: string | number | readonly string[] | undefined, fallback: number): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : fallback; + if (typeof value === 'string') { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} + +function resolveRangeProgress(props: InputHTMLAttributes): number { + const min = toNumber(props.min, 0); + const max = toNumber(props.max, 100); + const value = toNumber(props.value, min); + const span = max - min; + if (span <= 0) return 0; + const progress = ((value - min) / span) * 100; + return Math.min(100, Math.max(0, progress)); +} + const rangeInputClass = cn( - 'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent', - '[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-runnable-track]:bg-surface-sunken', - '[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent', - '[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken', - '[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent', + 'h-6 w-full cursor-pointer appearance-none bg-transparent [--range-track-h:0.5rem] [--range-thumb-h:1.25rem]', + 'focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-accent/25', + 'focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-accent/25', + 'disabled:cursor-not-allowed disabled:opacity-60', + '[&::-webkit-slider-runnable-track]:h-[var(--range-track-h)] [&::-webkit-slider-runnable-track]:rounded-full', + '[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--accent)_0%,var(--accent)_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_100%)]', + '[&::-webkit-slider-runnable-track]:shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_10%,transparent)]', + '[&::-webkit-slider-thumb]:mt-[calc((var(--range-track-h)-var(--range-thumb-h))/2)] [&::-webkit-slider-thumb]:h-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:w-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full', + '[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)]', + '[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-[0_2px_10px_color-mix(in_srgb,var(--accent)_45%,transparent)]', + '[&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:duration-150 [&::-webkit-slider-thumb]:ease-out', + 'active:[&::-webkit-slider-thumb]:scale-[1.07]', + '[&::-moz-range-track]:h-[var(--range-track-h)] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:border-0', + '[&::-moz-range-track]:bg-[color-mix(in_srgb,var(--offbase)_82%,var(--background))]', + '[&::-moz-range-progress]:h-[var(--range-track-h)] [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:border-0 [&::-moz-range-progress]:bg-accent', + '[&::-moz-range-thumb]:h-[var(--range-thumb-h)] [&::-moz-range-thumb]:w-[var(--range-thumb-h)] [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2', + '[&::-moz-range-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)] [&::-moz-range-thumb]:bg-accent', + '[&::-moz-range-thumb]:shadow-[0_2px_10px_color-mix(in_srgb,var(--accent)_45%,transparent)]', + '[&::-moz-range-thumb]:transition-transform [&::-moz-range-thumb]:duration-150 [&::-moz-range-thumb]:ease-out', + 'active:[&::-moz-range-thumb]:scale-[1.07]', ); function rangeInputClassName(className?: string) { @@ -15,7 +53,13 @@ function rangeInputClassName(className?: string) { export function RangeInput({ className, + style, ...props }: InputHTMLAttributes) { - return ; + const rangeStyle: RangeStyle = { + ...style, + '--range-progress': `${resolveRangeProgress(props)}%`, + }; + + return ; }