Skip to content

Commit 01f85a9

Browse files
feat: [Slider] implements
1 parent 967593e commit 01f85a9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2042
-5
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Enter the component you want most in the components, leave the emojis and follow
5353
| Select | 🚧 |
5454
| [Separator](https://vue-primitives.netlify.app/?path=/story/components-separator--styled) ||
5555
| Switch | 🚧 |
56-
| Slider | 🚧 |
56+
| [Slider](https://vue-primitives.netlify.app/?path=/story/components-slider--chromatic) | |
5757
| Switch | 🚧 |
5858
| [Tabs](https://vue-primitives.netlify.app/?path=/story/components-tabs--styled) ||
5959
| Toast | 🚧 |

packages/vue-primitives/src/checkbox/BubbleInput.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const props = withDefaults(defineProps<BubbleInputProps>(), {
1616
const elRef = shallowRef<HTMLInputElement>()
1717
1818
const controlSize = useSize(() => props.control)
19-
const initChecked = isIndeterminate(props.checked) ? false : props.checked
19+
// TODO: Check if this is the correct way to create a change event
20+
// const initChecked = isIndeterminate(props.checked) ? false : props.checked
2021
2122
// Bubble checked change to parents (e.g form change event)
2223
watch(() => props.checked, (checked, prevChecked) => {
@@ -44,7 +45,7 @@ watch(() => props.checked, (checked, prevChecked) => {
4445
type="checkbox"
4546
aria-hidden
4647
tabindex="-1"
47-
:checked="initChecked"
48+
:checked="isIndeterminate(props.checked) ? false : props.checked"
4849
:style="{
4950
width: `${controlSize?.width || 0}px`,
5051
height: `${controlSize?.width || 0}px`,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface BubbleInputProps {
2+
name: string | undefined
3+
value: string | number | readonly string[] | undefined
4+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<script setup lang="ts">
2+
import { shallowRef, watch } from 'vue'
3+
import type { BubbleInputProps } from './BubbleInput.ts'
4+
5+
defineOptions({
6+
name: 'BubbleInput',
7+
})
8+
9+
const props = defineProps<BubbleInputProps>()
10+
const elRef = shallowRef<HTMLInputElement>()
11+
// TODO: Check if this is the correct way to create a change event
12+
// const initValue = props.value
13+
14+
// Bubble checked change to parents (e.g form change event)
15+
watch(() => props.value, (value, prevValue) => {
16+
const input = elRef.value
17+
if (!input)
18+
return
19+
20+
const inputProto = window.HTMLInputElement.prototype
21+
const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'value') as PropertyDescriptor
22+
const setValue = descriptor.set
23+
24+
if (prevValue !== value && setValue) {
25+
// TODO: Check if this is the correct way to create a change event
26+
const event = new Event('change', { bubbles: true })
27+
setValue.call(input, value)
28+
input.dispatchEvent(event)
29+
}
30+
})
31+
32+
/**
33+
* We purposefully do not use `type="hidden"` here otherwise forms that
34+
* wrap it will not be able to access its value via the FormData API.
35+
*
36+
* We purposefully do not add the `value` attribute here to allow the value
37+
* to be set programatically and bubble to any parent form `onChange` event.
38+
* Adding the `value` will cause React to consider the programatic
39+
* dispatch a duplicate and it will get swallowed.
40+
*/
41+
</script>
42+
43+
<template>
44+
<input ref="elRef" :name="name" type="number" :style="{ display: 'none' }" :value="value">
45+
</template>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { HTMLAttributes, Ref } from 'vue'
2+
import { createContext } from '../hooks/createContext.ts'
3+
import type { Direction } from '../direction/index.ts'
4+
import type { PrimitiveProps } from '../primitive/Primitive.ts'
5+
import { createCollection } from '../collection/Collection.ts'
6+
7+
export const PAGE_KEYS = ['PageUp', 'PageDown']
8+
export const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
9+
10+
type SlideDirection = 'from-left' | 'from-right' | 'from-bottom' | 'from-top'
11+
export const BACK_KEYS: Record<SlideDirection, string[]> = {
12+
'from-left': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],
13+
'from-right': ['Home', 'PageDown', 'ArrowDown', 'ArrowRight'],
14+
'from-bottom': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'],
15+
'from-top': ['Home', 'PageDown', 'ArrowUp', 'ArrowLeft'],
16+
}
17+
18+
export interface SliderProps extends PrimitiveProps {
19+
name?: string
20+
disabled?: boolean
21+
orientation?: HTMLAttributes['aria-orientation']
22+
dir?: Direction
23+
min?: number
24+
max?: number
25+
step?: number
26+
minStepsBetweenThumbs?: number
27+
value?: number[]
28+
defaultValue?: number[]
29+
inverted?: boolean
30+
}
31+
32+
// eslint-disable-next-line ts/consistent-type-definitions
33+
export type SliderEmits = {
34+
'update:value': [value: number[]]
35+
'valueCommit': [value: number[]]
36+
}
37+
38+
export interface SliderContext {
39+
name: Ref<string | undefined>
40+
disabled: Ref<boolean>
41+
min: Ref<number>
42+
max: Ref<number>
43+
values: Ref<number[]>
44+
valueIndexToChangeRef: { value: number }
45+
thumbs: Set<HTMLElement>
46+
orientation: Ref<SliderProps['orientation']>
47+
}
48+
49+
export const [provideSliderContext, useSliderContext] = createContext<SliderContext>('Slider')
50+
51+
export const [Collection, useCollection] = createCollection<HTMLSpanElement, undefined>('Slider')
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<script setup lang="ts">
2+
import { type PropType, shallowRef, toRef, useAttrs } from 'vue'
3+
import { useControllableState } from '../hooks/useControllableState.ts'
4+
import { isFunction, isNumber } from '../utils/is.ts'
5+
import { clamp, getDecimalCount, roundValue } from '../utils/number.ts'
6+
import { composeEventHandlers } from '../utils/composeEventHandlers.ts'
7+
import { ARROW_KEYS, Collection, PAGE_KEYS, type SliderContext, type SliderProps, provideSliderContext } from './Slider.ts'
8+
import { getClosestValueIndex, getNextSortedValues, hasMinStepsBetweenValues } from './utils.ts'
9+
import SliderHorizontal from './SliderHorizontal.vue'
10+
import SliderVertical from './SliderVertical.vue'
11+
12+
defineOptions({
13+
name: 'Slider',
14+
inheritAttrs: false,
15+
})
16+
17+
const props = defineProps({
18+
as: {
19+
type: [String, Object] as PropType<SliderProps['as']>,
20+
required: false,
21+
default: 'span',
22+
},
23+
asChild: {
24+
type: [String, Object] as PropType<SliderProps['asChild']>,
25+
required: false,
26+
default: undefined,
27+
},
28+
name: {
29+
type: String,
30+
required: false,
31+
default: undefined,
32+
},
33+
disabled: {
34+
type: Boolean,
35+
required: false,
36+
default: false,
37+
},
38+
orientation: {
39+
type: String as PropType<Required<SliderProps>['orientation']>,
40+
required: false,
41+
default: 'horizontal',
42+
},
43+
dir: {
44+
type: String as PropType<Required<SliderProps>['dir']>,
45+
required: false,
46+
default: undefined,
47+
},
48+
min: {
49+
type: Number,
50+
required: false,
51+
default: 0,
52+
},
53+
max: {
54+
type: Number,
55+
rqeuired: false,
56+
default: 100,
57+
},
58+
step: {
59+
type: Number,
60+
required: false,
61+
default: 1,
62+
},
63+
minStepsBetweenThumbs: {
64+
type: Number,
65+
required: false,
66+
default: 0,
67+
},
68+
value: {
69+
type: [Array] as PropType<Required<SliderProps>['value']>,
70+
required: false,
71+
default: undefined,
72+
},
73+
defaultValue: {
74+
type: [Array] as PropType<Required<SliderProps>['defaultValue']>,
75+
required: false,
76+
default(rawProps: Record<string, unknown>) {
77+
return isNumber(rawProps.min) ? [rawProps.min] : [0]
78+
},
79+
},
80+
inverted: {
81+
type: Boolean,
82+
required: false,
83+
default: false,
84+
},
85+
})
86+
const emit = defineEmits<{
87+
'update:value': [value: number[]]
88+
'valueCommit': [value: number[]]
89+
}>()
90+
const attrs = useAttrs()
91+
const elRef = shallowRef<HTMLSpanElement>()
92+
93+
const thumbRefs: SliderContext['thumbs'] = new Set()
94+
const valueIndexToChangeRef: SliderContext['valueIndexToChangeRef'] = { value: 0 }
95+
96+
const values = useControllableState(
97+
props,
98+
(v) => {
99+
const thumbs = Array.from(thumbRefs)
100+
thumbs[valueIndexToChangeRef.value]?.focus()
101+
emit('update:value', v)
102+
},
103+
'value',
104+
props.defaultValue,
105+
)
106+
107+
let valuesBeforeSlideStartRef = values.value
108+
109+
function handleSlideStart(value: number) {
110+
if (props.disabled)
111+
return
112+
const closestIndex = getClosestValueIndex(values.value, value)
113+
updateValues(value, closestIndex)
114+
}
115+
116+
function handleSlideMove(value: number) {
117+
if (props.disabled)
118+
return
119+
updateValues(value, valueIndexToChangeRef.value)
120+
}
121+
122+
function handleSlideEnd() {
123+
if (props.disabled)
124+
return
125+
const prevValue = valuesBeforeSlideStartRef[valueIndexToChangeRef.value]
126+
const nextValue = values.value[valueIndexToChangeRef.value]
127+
const hasChanged = nextValue !== prevValue
128+
if (hasChanged)
129+
emit('valueCommit', values.value)
130+
}
131+
132+
function handleStepKeydown({ event, direction: stepDirection }: { event: KeyboardEvent, direction: number }) {
133+
if (props.disabled)
134+
return
135+
const isPageKey = PAGE_KEYS.includes(event.key)
136+
const isSkipKey = isPageKey || (event.shiftKey && ARROW_KEYS.includes(event.key))
137+
const multiplier = isSkipKey ? 10 : 1
138+
const atIndex = valueIndexToChangeRef.value
139+
const value = values.value[atIndex]!
140+
const stepInDirection = props.step * multiplier * stepDirection
141+
updateValues(value + stepInDirection, atIndex, { commit: true })
142+
}
143+
144+
function updateValues(value: number, atIndex: number, { commit } = { commit: false }) {
145+
const decimalCount = getDecimalCount(props.step)
146+
const snapToStep = roundValue(Math.round((value - props.min) / props.step) * props.step + props.min, decimalCount)
147+
const nextValue = clamp(snapToStep, [props.min, props.max])
148+
149+
const prevValues = values.value
150+
const nextValues = getNextSortedValues(values.value, nextValue, atIndex)
151+
if (hasMinStepsBetweenValues(nextValues, props.minStepsBetweenThumbs * props.step)) {
152+
valueIndexToChangeRef.value = nextValues.indexOf(nextValue)
153+
const hasChanged = String(nextValues) !== String(prevValues)
154+
if (hasChanged && commit)
155+
emit('valueCommit', nextValues)
156+
values.value = nextValues
157+
}
158+
}
159+
160+
const onPointerdown = composeEventHandlers<PointerEvent>((event) => {
161+
isFunction(attrs.onPointerdown) && attrs.onPointerdown(event)
162+
}, () => {
163+
if (!props.disabled)
164+
valuesBeforeSlideStartRef = values.value
165+
})
166+
167+
Collection.provideCollectionContext(elRef)
168+
169+
provideSliderContext({
170+
name: toRef(props, 'name'),
171+
disabled: toRef(props, 'disabled'),
172+
min: toRef(props.min),
173+
max: toRef(props.max),
174+
valueIndexToChangeRef,
175+
thumbs: thumbRefs,
176+
values,
177+
orientation: toRef(props.orientation),
178+
})
179+
</script>
180+
181+
<template>
182+
<component
183+
:is="orientation === 'horizontal' ? SliderHorizontal : SliderVertical"
184+
:ref="(el: any) => elRef = el?.$el"
185+
:as="as"
186+
:as-child="asChild"
187+
:aria-disabled="disabled"
188+
:data-disabled="disabled ? '' : undefined"
189+
:dir="dir"
190+
v-bind="{
191+
...attrs,
192+
onPointerdown,
193+
}"
194+
:min="min"
195+
:max="max"
196+
:inverted="inverted"
197+
@slide-start="handleSlideStart"
198+
@slide-move="handleSlideMove"
199+
@slide-end="handleSlideEnd"
200+
@step-keydown="handleStepKeydown"
201+
>
202+
<slot />
203+
</component>
204+
</template>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { Direction } from '../direction/index.ts'
2+
import type { SliderOrientationProps } from './SliderOrientation.ts'
3+
4+
export interface SliderHorizontalProps extends SliderOrientationProps {
5+
dir?: Direction
6+
}

0 commit comments

Comments
 (0)