From c169d70b15b815c603cf3b5483caf2964ac5d868 Mon Sep 17 00:00:00 2001 From: J-Sek Date: Mon, 9 Mar 2026 20:49:54 +0100 Subject: [PATCH] feat(VSlider): add variant "pill" to align with MD3 --- .../components/VRangeSlider/VRangeSlider.tsx | 1 + .../src/components/VSlider/VSlider.sass | 5 + .../src/components/VSlider/VSlider.tsx | 1 + .../src/components/VSlider/VSliderThumb.sass | 45 ++++++- .../src/components/VSlider/VSliderThumb.tsx | 5 +- .../src/components/VSlider/VSliderTrack.sass | 121 ++++++++++++++++++ .../src/components/VSlider/VSliderTrack.tsx | 90 +++++++------ .../src/components/VSlider/_variables.scss | 13 ++ .../vuetify/src/components/VSlider/slider.ts | 9 ++ 9 files changed, 247 insertions(+), 43 deletions(-) diff --git a/packages/vuetify/src/components/VRangeSlider/VRangeSlider.tsx b/packages/vuetify/src/components/VRangeSlider/VRangeSlider.tsx index b82871698e7..6a03046ce50 100644 --- a/packages/vuetify/src/components/VRangeSlider/VRangeSlider.tsx +++ b/packages/vuetify/src/components/VRangeSlider/VRangeSlider.tsx @@ -159,6 +159,7 @@ export const VRangeSlider = genericComponent()({ 'v-slider--focused': isFocused.value, 'v-slider--pressed': mousePressed.value, 'v-slider--disabled': disabled.value, + [`v-slider--variant-${props.variant}`]: props.variant !== 'default', }, rtlClasses.value, props.class, diff --git a/packages/vuetify/src/components/VSlider/VSlider.sass b/packages/vuetify/src/components/VSlider/VSlider.sass index 93e5f4ca654..5e21c9cc5f3 100644 --- a/packages/vuetify/src/components/VSlider/VSlider.sass +++ b/packages/vuetify/src/components/VSlider/VSlider.sass @@ -69,3 +69,8 @@ .v-slider__label margin-inline-end: $slider-label-margin-end + + // Pill variant + .v-slider--variant-pill + .v-slider__container + cursor: pointer diff --git a/packages/vuetify/src/components/VSlider/VSlider.tsx b/packages/vuetify/src/components/VSlider/VSlider.tsx index 126768a71c7..c2b986c5392 100644 --- a/packages/vuetify/src/components/VSlider/VSlider.tsx +++ b/packages/vuetify/src/components/VSlider/VSlider.tsx @@ -124,6 +124,7 @@ export const VSlider = genericComponent()({ 'v-slider--focused': isFocused.value, 'v-slider--pressed': mousePressed.value, 'v-slider--disabled': disabled.value, + [`v-slider--variant-${props.variant}`]: props.variant !== 'default', }, rtlClasses.value, props.class, diff --git a/packages/vuetify/src/components/VSlider/VSliderThumb.sass b/packages/vuetify/src/components/VSlider/VSliderThumb.sass index a79bce6f2b2..201a6fecd5b 100644 --- a/packages/vuetify/src/components/VSlider/VSliderThumb.sass +++ b/packages/vuetify/src/components/VSlider/VSliderThumb.sass @@ -148,6 +148,45 @@ .v-slider-thumb__surface::before transform: scale(2) - .v-slider-thumb:hover:not(.v-slider-thumb--focused) - .v-slider-thumb__surface::before - opacity: $slider-thumb-hover-opacity + .v-slider--variant-pill + .v-slider-thumb__surface + border-radius: $slider-pill-thumb-border-radius + + &::before, + &::after + display: none + + &.v-input--horizontal .v-slider-thumb + inset-inline-start: calc(var(--v-slider-thumb-position) - #{$slider-pill-thumb-width} / 2) + + > .v-slider-thumb__surface + width: $slider-pill-thumb-width + min-height: $slider-pill-thumb-min-height + + &:focus-visible, + &.v-slider-thumb--focused + > .v-slider-thumb__surface + width: $slider-pill-focused-thumb-width + margin-inline-start: calc((#{$slider-pill-thumb-width} - #{$slider-pill-focused-thumb-width}) / 2) + + &.v-input--vertical .v-slider-thumb + top: calc(var(--v-slider-thumb-position) - #{$slider-pill-thumb-width} / 2) + + > .v-slider-thumb__surface + height: $slider-pill-thumb-width + min-width: $slider-pill-thumb-min-height + + &:focus-visible, + &.v-slider-thumb--focused + > .v-slider-thumb__surface + height: $slider-pill-focused-thumb-width + margin-top: calc((#{$slider-pill-thumb-width} - #{$slider-pill-focused-thumb-width}) / 2) + + .v-slider-thumb__ripple + display: none + + .v-slider-thumb:focus-visible, + .v-slider-thumb--focused + .v-slider-thumb__surface + outline: 3px solid currentColor + outline-offset: 2px diff --git a/packages/vuetify/src/components/VSlider/VSliderThumb.tsx b/packages/vuetify/src/components/VSlider/VSliderThumb.tsx index 83ebbb5a362..7694ec5b374 100644 --- a/packages/vuetify/src/components/VSlider/VSliderThumb.tsx +++ b/packages/vuetify/src/components/VSlider/VSliderThumb.tsx @@ -78,6 +78,7 @@ export const VSliderThumb = genericComponent()({ step, disabled, thumbSize, + trackSize, thumbLabel, direction, isReversed, @@ -87,10 +88,12 @@ export const VSliderThumb = genericComponent()({ mousePressed, decimals, indexFromEnd, + variant, } = slider const isHovered = shallowRef(false) const isHidden = shallowRef(false) + const isPill = computed(() => variant.value === 'pill') const elevationProps = computed(() => !disabled.value ? elevation.value : undefined) const { elevationClasses } = useElevation(elevationProps) @@ -174,7 +177,7 @@ export const VSliderThumb = genericComponent()({ style={[ { '--v-slider-thumb-position': positionPercentage, - '--v-slider-thumb-size': convertToUnit(thumbSize.value), + '--v-slider-thumb-size': convertToUnit(isPill.value ? (trackSize.value + 8) : thumbSize.value), }, props.style, ]} diff --git a/packages/vuetify/src/components/VSlider/VSliderTrack.sass b/packages/vuetify/src/components/VSlider/VSliderTrack.sass index 167b4a48871..f2fb2441195 100644 --- a/packages/vuetify/src/components/VSlider/VSliderTrack.sass +++ b/packages/vuetify/src/components/VSlider/VSliderTrack.sass @@ -86,12 +86,17 @@ touch-action: pan-y &__background + inset-inline-start: 0 + width: 100% height: var(--v-slider-track-size) &__fill + inset-inline-start: var(--v-slider-value-start) + width: calc(var(--v-slider-value-stop) - var(--v-slider-value-start)) height: inherit .v-slider-track__tick + inset-inline-start: var(--v-slider-tick-position) margin-top: calc(#{$slider-track-active-size} / 2) @include tools.rtl() @@ -136,15 +141,20 @@ touch-action: pan-x &__background + top: 0 + height: 100% width: var(--v-slider-track-size) &__fill + top: calc(100% - var(--v-slider-value-stop)) + height: calc(var(--v-slider-value-stop) - var(--v-slider-value-start)) width: inherit .v-slider-track__ticks height: 100% .v-slider-track__tick + bottom: var(--v-slider-tick-position) margin-inline-start: calc(#{$slider-track-active-size} / 2) transform: translate(calc(var(--v-slider-tick-size) / -2), calc(var(--v-slider-tick-size) / 2)) @@ -167,3 +177,114 @@ .v-slider-track__background--opacity opacity: 0.38 + + // Pill variant + .v-slider--variant-pill + --v-slider-pill-gap: #{$slider-pill-gap} + --v-slider-pill-thumb-width: #{$slider-pill-thumb-width} + + .v-slider-track + border-radius: $slider-pill-track-outer-radius + + .v-slider-track__background, + .v-slider-track__fill + border-radius: $slider-pill-track-outer-radius + + .v-slider-track__background--opacity + opacity: 0.24 + + &.v-input--horizontal + .v-slider-track + height: var(--v-slider-track-size) + + &__background + height: var(--v-slider-track-size) + + &__fill + inset-inline-start: calc(var(--v-slider-value-start) + var(--v-slider-pill-thumb-width) / 2 + var(--v-slider-pill-gap)) + width: calc(var(--v-slider-value-stop) - var(--v-slider-value-start) - var(--v-slider-pill-thumb-width) - var(--v-slider-pill-gap) * 2) + height: var(--v-slider-track-size) + + .v-slider-track__inactive--before + inset-inline-start: 0 + width: calc(var(--v-slider-value-start) - var(--v-slider-pill-thumb-width) / 2 - var(--v-slider-pill-gap)) + border-radius: $slider-pill-track-outer-radius $slider-pill-track-inner-radius $slider-pill-track-inner-radius $slider-pill-track-outer-radius + + ~ .v-slider-track__fill + border-top-left-radius: $slider-pill-track-inner-radius + border-bottom-left-radius: $slider-pill-track-inner-radius + + .v-slider-track__inactive--after + inset-inline-start: calc(var(--v-slider-value-stop) + var(--v-slider-pill-thumb-width) / 2 + var(--v-slider-pill-gap)) + width: calc(100% - var(--v-slider-value-stop) - var(--v-slider-pill-thumb-width) / 2 - var(--v-slider-pill-gap)) + border-radius: $slider-pill-track-inner-radius $slider-pill-track-outer-radius $slider-pill-track-outer-radius $slider-pill-track-inner-radius + + ~ .v-slider-track__fill + border-top-right-radius: $slider-pill-track-inner-radius + border-bottom-right-radius: $slider-pill-track-inner-radius + + .v-slider-track__inactive--before, + .v-slider-track__inactive--after + &::before + content: '' + position: absolute + width: $slider-pill-stop-indicator-size + height: $slider-pill-stop-indicator-size + border-radius: $slider-pill-stop-indicator-border-radius + background: currentColor + top: 50% + transform: translateY(-50%) + + .v-slider-track__inactive--before::before + left: calc(#{$slider-pill-gap} - #{$slider-pill-stop-indicator-size} / 2) + + .v-slider-track__inactive--after::before + right: calc(#{$slider-pill-gap} - #{$slider-pill-stop-indicator-size} / 2) + + &.v-input--vertical + .v-slider-track + width: var(--v-slider-track-size) + + &__background + width: var(--v-slider-track-size) + + &__fill + top: calc(100% - var(--v-slider-value-stop) + var(--v-slider-pill-thumb-width) / 2 + var(--v-slider-pill-gap)) + height: calc(var(--v-slider-value-stop) - var(--v-slider-value-start) - var(--v-slider-pill-thumb-width) - var(--v-slider-pill-gap) * 2) + width: var(--v-slider-track-size) + + .v-slider-track__inactive--before + top: calc(100% - var(--v-slider-value-start) + var(--v-slider-pill-thumb-width) / 2 + var(--v-slider-pill-gap)) + height: calc(var(--v-slider-value-start) - var(--v-slider-pill-thumb-width) / 2 - var(--v-slider-pill-gap)) + border-radius: $slider-pill-track-inner-radius $slider-pill-track-inner-radius $slider-pill-track-outer-radius $slider-pill-track-outer-radius + + ~ .v-slider-track__fill + border-bottom-left-radius: $slider-pill-track-inner-radius + border-bottom-right-radius: $slider-pill-track-inner-radius + + .v-slider-track__inactive--after + top: 0 + height: calc(100% - var(--v-slider-value-stop) - var(--v-slider-pill-thumb-width) / 2 - var(--v-slider-pill-gap)) + border-radius: $slider-pill-track-outer-radius $slider-pill-track-outer-radius $slider-pill-track-inner-radius $slider-pill-track-inner-radius + + ~ .v-slider-track__fill + border-top-left-radius: $slider-pill-track-inner-radius + border-top-right-radius: $slider-pill-track-inner-radius + + .v-slider-track__inactive--before, + .v-slider-track__inactive--after + &::before + content: '' + position: absolute + width: $slider-pill-stop-indicator-size + height: $slider-pill-stop-indicator-size + border-radius: $slider-pill-stop-indicator-border-radius + background: currentColor + left: 50% + transform: translateX(-50%) + + .v-slider-track__inactive--before::before + bottom: calc(#{$slider-pill-gap} - #{$slider-pill-stop-indicator-size} / 2) + + .v-slider-track__inactive--after::before + top: calc(#{$slider-pill-gap} - #{$slider-pill-stop-indicator-size} / 2) diff --git a/packages/vuetify/src/components/VSlider/VSliderTrack.tsx b/packages/vuetify/src/components/VSlider/VSliderTrack.tsx index 8a4ce58e9ab..de5dacc914a 100644 --- a/packages/vuetify/src/components/VSlider/VSliderTrack.tsx +++ b/packages/vuetify/src/components/VSlider/VSliderTrack.tsx @@ -54,12 +54,15 @@ export const VSliderTrack = genericComponent()({ trackColor, trackFillColor, trackSize, + variant, vertical, min, max, - indexFromEnd, + // indexFromEnd, TODO: understand and restore behavior } = slider + const isPill = computed(() => variant.value === 'pill') + const { roundedClasses } = useRounded(rounded) const { @@ -72,32 +75,15 @@ export const VSliderTrack = genericComponent()({ backgroundColorStyles: trackColorStyles, } = useBackgroundColor(trackColor) - const startDir = computed(() => `inset-${vertical.value ? 'block' : 'inline'}-${indexFromEnd.value ? 'end' : 'start'}`) - const endDir = computed(() => vertical.value ? 'height' : 'width') - - const backgroundStyles = computed(() => { - return { - [startDir.value]: '0%', - [endDir.value]: '100%', - } - }) - - const trackFillWidth = computed(() => props.stop - props.start) - - const trackFillStyles = computed(() => { - return { - [startDir.value]: convertToUnit(props.start, '%'), - [endDir.value]: convertToUnit(trackFillWidth.value, '%'), - } - }) - const computedTicks = computed(() => { if (!showTicks.value) return [] const ticks = vertical.value ? parsedTicks.value.slice().reverse() : parsedTicks.value return ticks.map((tick, index) => { - const directionValue = tick.value !== min.value && tick.value !== max.value ? convertToUnit(tick.position, '%') : undefined + const directionValue = tick.value !== min.value && tick.value !== max.value + ? convertToUnit(tick.position, '%') + : undefined return (
()({ 'v-slider-track__tick--last': tick.value === max.value, }, ]} - style={{ [startDir.value]: directionValue }} + style={{ '--v-slider-tick-position': directionValue }} > { (tick.label || slots['tick-label']) && ( @@ -136,32 +122,58 @@ export const VSliderTrack = genericComponent()({ { '--v-slider-track-size': convertToUnit(trackSize.value), '--v-slider-tick-size': convertToUnit(tickSize.value), + '--v-slider-value-start': convertToUnit(props.start, '%'), + '--v-slider-value-stop': convertToUnit(props.stop, '%'), }, props.style, ]} > -
+ { isPill.value ? ( + <> + { props.start > 0 && ( +
+ )} +
+ + ) : ( +
+ )}
{ showTicks.value && ( diff --git a/packages/vuetify/src/components/VSlider/_variables.scss b/packages/vuetify/src/components/VSlider/_variables.scss index cb27af702de..24651f5414b 100644 --- a/packages/vuetify/src/components/VSlider/_variables.scss +++ b/packages/vuetify/src/components/VSlider/_variables.scss @@ -33,3 +33,16 @@ $slider-vertical-margin-top: 12px !default; $slider-vertical-min-height: 300px !default; $slider-track-active-size: calc(var(--v-slider-track-size) + #{$slider-track-active-size-offset}) !default; + +// Pill variant +$slider-pill-track-size: 16px !default; +$slider-pill-track-outer-radius: 16px !default; +$slider-pill-track-inner-radius: 2px !default; +$slider-pill-thumb-width: 4px !default; +$slider-pill-focused-thumb-width: 2px !default; +$slider-pill-thumb-min-height: 44px !default; +$slider-pill-thumb-border-radius: 4px !default; +$slider-pill-thumb-pressed-width: 2px !default; +$slider-pill-gap: 6px !default; +$slider-pill-stop-indicator-size: 4px !default; +$slider-pill-stop-indicator-border-radius: 50% !default; diff --git a/packages/vuetify/src/components/VSlider/slider.ts b/packages/vuetify/src/components/VSlider/slider.ts index 462b57a6c8b..43e2771be28 100644 --- a/packages/vuetify/src/components/VSlider/slider.ts +++ b/packages/vuetify/src/components/VSlider/slider.ts @@ -19,6 +19,8 @@ export type Tick = { label?: string } +export type SliderVariant = 'default' | 'pill' + type SliderProvide = { activeThumbRef: Ref color: Ref @@ -51,6 +53,7 @@ type SliderProvide = { ticks: Ref | undefined> tickSize: Ref trackContainerRef: Ref + variant: Ref vertical: Ref parsedTicks: Ref hasLabels: Ref @@ -133,6 +136,11 @@ export const makeSliderProps = propsFactory({ }, reverse: Boolean, noKeyboard: Boolean, + variant: { + type: String as PropType, + default: 'default', + validator: (v: any) => ['default', 'pill'].includes(v), + }, ...makeRoundedProps(), ...makeElevationProps({ @@ -385,6 +393,7 @@ export const useSlider = ({ trackContainerRef, trackFillColor, trackSize, + variant: toRef(() => props.variant ?? 'default') as Ref, vertical, }