diff --git a/packages/ui/src/components/base/Slider.vue b/packages/ui/src/components/base/Slider.vue index 1188353c63..e4d8339da5 100644 --- a/packages/ui/src/components/base/Slider.vue +++ b/packages/ui/src/components/base/Slider.vue @@ -2,7 +2,8 @@
{{ minLabel ?? min }} @@ -13,7 +14,6 @@ :class="[heightClass, disabled ? 'opacity-50' : '']" > +
+ +
+ +
+
+
@@ -45,24 +68,12 @@ >
- -
- -
{{ maxLabel ?? formatValue(max) }} @@ -72,7 +83,8 @@ type="number" :size="size" wrapper-class="slider-value shrink-0" - :style="{ width: currentValue === null ? '100%' : inputWidth }" + :class="currentValue === null ? 'w-full' : undefined" + :style="currentValue === null ? undefined : { '--value-chars': valueFieldChars }" :input-class="currentValue === null ? undefined : 'text-center'" :disabled="disabled" :placeholder="placeholder" @@ -132,15 +144,17 @@ const heightClass = computed( large: 'h-12', })[props.size], ) -const currentValue = ref(props.modelValue === null ? null : normalizeValue(props.modelValue)) -const inputWidth = computed(() => { - const digits = Math.max(String(props.min).length, String(props.max).length) - const padding = props.size === 'small' || props.size === 'standard' ? 1.5 : 2 - return `max(65px, calc(${digits}ch + ${padding}rem + 2px))` +const labelSizeClass = computed(() => (props.size === 'small' ? 'text-sm' : 'text-base')) +const valueFieldChars = computed(() => { + const decimals = (String(props.step).split('.')[1] ?? '').length + + return Math.max(props.min.toFixed(decimals).length, props.max.toFixed(decimals).length, 2) }) +const currentValue = ref(props.modelValue === null ? null : normalizeValue(props.modelValue)) +const previousRawValue = ref(null) const currentPercentage = computed(() => getPercentage(currentValue.value ?? props.min)) const visibleSnapPoints = computed(() => - props.snapPoints.filter((snapPoint) => snapPoint >= props.min && snapPoint <= props.max), + props.snapPoints.filter((snapPoint) => snapPoint > props.min && snapPoint < props.max), ) watch( @@ -180,14 +194,25 @@ function inputValueValid(inputValue: number) { } function onInputWithSnap(value: string) { - let parsedValue = Number.parseFloat(value) + const parsedValue = Number.parseFloat(value) + const previousValue = previousRawValue.value ?? currentValue.value ?? props.min + + let snappedValue = parsedValue + let closestDistance = props.snapRange for (const snapPoint of props.snapPoints) { const distance = Math.abs(snapPoint - parsedValue) - if (distance < props.snapRange) parsedValue = snapPoint + const previousDistance = Math.abs(snapPoint - previousValue) + + // Only pull toward a snap point while approaching it, not while moving away + if (distance < closestDistance && distance < previousDistance) { + closestDistance = distance + snappedValue = snapPoint + } } - inputValueValid(parsedValue) + previousRawValue.value = parsedValue + inputValueValid(snappedValue) } function onInput(event: Event) { @@ -246,15 +271,15 @@ function onInput(event: Event) { opacity: 1; } - &:focus-visible + .slider-track .slider-thumb { + &:focus-visible ~ .slider-track .slider-thumb { outline: 3px solid var(--color-focus-ring); outline-offset: 3px; } &:hover, &:focus-visible { - & + .slider-track .filled-slider-track, - & ~ .snap-points .brightness-on-hover { + & ~ .slider-track .filled-slider-track, + & ~ .snap-points .bg-brand { filter: brightness(var(--hover-brightness)); } } @@ -262,6 +287,8 @@ function onInput(event: Event) { .slider-value :deep(input[type='number']) { -moz-appearance: textfield; + flex: none; + width: calc(var(--value-chars) * 1ch); &::-webkit-inner-spin-button, &::-webkit-outer-spin-button { @@ -276,4 +303,11 @@ function onInput(event: Event) { transition: none; } } + +.snap-points-filled { + transition: clip-path 0.25s var(--ease-out-expo); + @media (prefers-reduced-motion) { + transition: none; + } +} diff --git a/packages/ui/src/stories/base/Slider.stories.ts b/packages/ui/src/stories/base/Slider.stories.ts index 4be91681ce..bdc5036a28 100644 --- a/packages/ui/src/stories/base/Slider.stories.ts +++ b/packages/ui/src/stories/base/Slider.stories.ts @@ -37,6 +37,24 @@ export const WithSnapPoints: Story = { }, } +export const DecimalSteps: Story = { + args: { + modelValue: 0.5, + min: 0, + max: 1, + step: 0.05, + }, +} + +export const Small: Story = { + args: { + size: 'small', + modelValue: 50, + min: 0, + max: 100, + }, +} + export const Disabled: Story = { args: { modelValue: 50,