Component: Vue Widget Slider (new) (#5516)

* feat: Initial shadcn configuration

* component: Add Slider component from shadcn-vue

* deps: Add tw-animate-css

* component: Align slider with Figma styles

* component: Set the step value for the slider, update styles

* fix: update component tests to work with Array of values

* vite: Don't reload dev server for test changes

* component: Swap text for a number input kept in sync with the slider

* cleanup: Don't need the override if the input isn't type="number"

* test: add step size tests

* cleanup: Don't need cn for these

* css: Update token names to match new Figma Variables

* lint: Fix camelCase vs train-case in passthrough

* feat: If the value is deleted, revert to the slider state cc: @PabloWiedemann

* feat: Improve cursor styles, grabbable thumb, clickable track

* lint: temporarily disable some warnings

* feat: Grabbing while sliding (most of the time)
This commit is contained in:
Alexander Brown
2025-09-12 18:52:18 -07:00
committed by GitHub
parent c588f2f457
commit 1845708ddb
12 changed files with 402 additions and 150 deletions

View File

@@ -2,8 +2,9 @@
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/utilities' layer(utilities);
@import 'tw-animate-css';
@plugin "tailwindcss-primeui";
@plugin 'tailwindcss-primeui';
@config '../../../tailwind.config.ts';
@@ -114,6 +115,14 @@
--color-dark-elevation-2: rgba(from white r g b / 0.03);
}
@theme inline {
--color-node-component-surface: var(--color-charcoal-300);
--color-node-component-surface-highlight: var(--color-slate-100);
--color-node-component-surface-hovered: var(--color-charcoal-500);
--color-node-component-surface-selected: var(--color-charcoal-700);
--color-node-stroke: var(--color-stone-100);
}
@custom-variant dark-theme {
.dark-theme & {
@slot;

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { reactiveOmit } from '@vueuse/core'
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
import {
SliderRange,
SliderRoot,
SliderThumb,
SliderTrack,
useForwardPropsEmits
} from 'reka-ui'
import { type HTMLAttributes, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
SliderRootProps & { class?: HTMLAttributes['class'] }
>()
const pressed = ref(false)
const setPressed = (val: boolean) => {
pressed.value = val
}
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SliderRoot
v-slot="{ modelValue }"
data-slot="slider"
:class="
cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
'data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
props.class
)
"
v-bind="forwarded"
@slide-start="() => setPressed(true)"
@slide-move="() => setPressed(true)"
@slide-end="() => setPressed(false)"
>
<SliderTrack
data-slot="slider-track"
:class="
cn(
'bg-node-stroke relative grow overflow-hidden rounded-full',
'cursor-pointer',
'data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:w-full',
'data-[orientation=vertical]:h-full data-[orientation=vertical]:w-0.5'
)
"
>
<SliderRange
data-slot="slider-range"
class="bg-node-component-surface-highlight absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
data-slot="slider-thumb"
:class="
cn(
'bg-node-component-surface-highlight ring-node-component-surface-selected block size-3.5 shrink-0 rounded-full shadow-sm transition-[color,box-shadow]',
'cursor-grab',
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
)
"
/>
</SliderRoot>
</template>

View File

@@ -1,62 +1,63 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Slider from 'primevue/slider'
import type { SliderProps } from 'primevue/slider'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import Slider from '@/components/ui/slider/Slider.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
describe('WidgetInputNumberSlider Value Binding', () => {
const createMockWidget = (
value: number = 5,
options: Partial<SliderProps & { precision?: number }> = {},
callback?: (value: number) => void
): SimplifiedWidget<number> => ({
function createMockWidget(
value: number = 5,
options: SimplifiedWidget['options'] = {},
callback?: (value: number) => void
): SimplifiedWidget<number> {
return {
name: 'test_slider',
type: 'float',
value,
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
callback
})
const mountComponent = (
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) => {
return mount(WidgetInputNumberSlider, {
global: {
plugins: [PrimeVue],
components: { InputText, Slider }
},
props: {
widget,
modelValue,
readonly
}
})
}
}
const getNumberInput = (wrapper: ReturnType<typeof mount>) => {
const input = wrapper.find('input[type="number"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error(
'Number input element not found or is not an HTMLInputElement'
)
function mountComponent(
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) {
return mount(WidgetInputNumberSlider, {
global: {
plugins: [PrimeVue],
components: { InputNumber, Slider }
},
props: {
widget,
modelValue,
readonly
}
return { element: input.element }
}
})
}
function getNumberInput(wrapper: ReturnType<typeof mount>) {
const input = wrapper.find('input[inputmode="numeric"]')
if (!(input.element instanceof HTMLInputElement)) {
throw new Error(
'Number input element not found or is not an HTMLInputElement'
)
}
return input.element
}
describe('WidgetInputNumberSlider Value Binding', () => {
describe('Props and Values', () => {
it('passes modelValue to slider component', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('modelValue')).toBe(5)
expect(slider.props('modelValue')).toEqual([5])
})
it('handles different initial values', () => {
@@ -67,10 +68,10 @@ describe('WidgetInputNumberSlider Value Binding', () => {
const wrapper2 = mountComponent(widget2, 10)
const slider1 = wrapper1.findComponent({ name: 'Slider' })
expect(slider1.props('modelValue')).toBe(5)
expect(slider1.props('modelValue')).toEqual([5])
const slider2 = wrapper2.findComponent({ name: 'Slider' })
expect(slider2.props('modelValue')).toBe(10)
expect(slider2.props('modelValue')).toEqual([10])
})
})
@@ -85,8 +86,9 @@ describe('WidgetInputNumberSlider Value Binding', () => {
it('renders input field', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
console.log(wrapper.html())
expect(wrapper.find('input[type="number"]').exists()).toBe(true)
expect(wrapper.find('input[inputmode="numeric"]').exists()).toBe(true)
})
it('displays initial value in input field', () => {
@@ -94,7 +96,7 @@ describe('WidgetInputNumberSlider Value Binding', () => {
const wrapper = mountComponent(widget, 42)
const input = getNumberInput(wrapper)
expect(input.element.value).toBe('42')
expect(input.value).toBe('42')
})
it('disables components in readonly mode', () => {
@@ -105,7 +107,7 @@ describe('WidgetInputNumberSlider Value Binding', () => {
expect(slider.props('disabled')).toBe(true)
const input = getNumberInput(wrapper)
expect(input.element.disabled).toBe(true)
expect(input.disabled).toBe(true)
})
})
@@ -127,5 +129,47 @@ describe('WidgetInputNumberSlider Value Binding', () => {
expect(slider.props('min')).toBe(-100)
expect(slider.props('max')).toBe(100)
})
describe('Step Size', () => {
it('should default to 1', () => {
const widget = createMockWidget(5)
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(1)
})
it('should get the step2 value if present', () => {
const widget = createMockWidget(5, { step2: 0.01 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(0.01)
})
it('should be 1 for precision 0', () => {
const widget = createMockWidget(5, { precision: 0 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(1)
})
it('should be .1 for precision 1', () => {
const widget = createMockWidget(5, { precision: 1 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(0.1)
})
it('should be .00001 for precision 5', () => {
const widget = createMockWidget(5, { precision: 5 })
const wrapper = mountComponent(widget, 5)
const slider = wrapper.findComponent({ name: 'Slider' })
expect(slider.props('step')).toBe(0.00001)
})
})
})
})

View File

@@ -6,31 +6,35 @@
"
>
<Slider
v-model="localValue"
:model-value="[localValue]"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow text-xs"
@update:model-value="onChange"
/>
<InputText
v-model="inputDisplayValue"
:disabled="readonly"
type="number"
:step="stepValue"
class="w-[4em] text-center text-xs px-0 !border-none !shadow-none !bg-transparent"
@update:model-value="updateLocalValue"
/>
<InputNumber
:key="timesEmptied"
:model-value="localValue"
v-bind="filteredProps"
:disabled="readonly"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
size="small"
@blur="handleInputBlur"
@keydown="handleInputKeydown"
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
class="w-16"
@update:model-value="handleNumberInputUpdate"
/>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Slider from 'primevue/slider'
import { computed, ref, watch } from 'vue'
import InputNumber from 'primevue/inputnumber'
import { computed, ref } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -42,7 +46,7 @@ import {
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
const { widget, modelValue, readonly } = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
@@ -53,19 +57,29 @@ const emit = defineEmits<{
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useNumberWidgetValue(
props.widget,
props.modelValue,
emit
)
const { localValue, onChange } = useNumberWidgetValue(widget, modelValue, emit)
const timesEmptied = ref(0)
const updateLocalValue = (newValue: number[] | undefined): void => {
onChange(newValue ?? [localValue.value])
}
const handleNumberInputUpdate = (newValue: number | undefined) => {
if (newValue) {
updateLocalValue([newValue])
return
}
timesEmptied.value += 1
}
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
const p = widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
@@ -73,96 +87,21 @@ const precision = computed(() => {
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (props.widget.options?.step2 !== undefined) {
return String(props.widget.options.step2)
if (widget.options?.step2 !== undefined) {
return widget.options.step2
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return '1'
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return (1 / Math.pow(10, precision.value)).toFixed(precision.value)
}
// Default to 'any' for unrestricted stepping
return 'any'
})
// Format a number according to the widget's precision
const formatNumber = (value: number): string => {
if (precision.value === undefined) {
// No precision specified, return as-is
return String(value)
return undefined
}
// Use toFixed to ensure correct decimal places
return value.toFixed(precision.value)
}
// Apply precision-based rounding to a number
const applyPrecision = (value: number): number => {
if (precision.value === undefined) {
// No precision specified, return as-is
return value
}
if (precision.value === 0) {
// Integer precision
return Math.round(value)
return 1
}
// Round to the specified decimal places
const multiplier = Math.pow(10, precision.value)
return Math.round(value * multiplier) / multiplier
}
// Keep a separate display value for the input field
const inputDisplayValue = ref(formatNumber(localValue.value))
// Update display value when localValue changes from external sources
watch(localValue, (newValue) => {
inputDisplayValue.value = formatNumber(newValue)
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return 1 / Math.pow(10, precision.value)
})
const handleInputBlur = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
}
</script>
<style scoped>
/* Remove number input spinners */
:deep(input[type='number']::-webkit-inner-spin-button),
:deep(input[type='number']::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:deep(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View File

@@ -19,7 +19,7 @@ defineProps<{
{{ widget.name }}
</p>
<div
class="w-75"
class="w-75 cursor-default"
@pointerdown.stop="noop"
@pointermove.stop="noop"
@pointerup.stop="noop"

View File

@@ -1,4 +1,6 @@
export const WidgetInputBaseClass = [
import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'bg-zinc-500/10',
// Outline
@@ -11,4 +13,4 @@ export const WidgetInputBaseClass = [
'!rounded-lg',
// Hover
'hover:outline-blue-500/80'
].join(' ')
])