From 885ff97c10fa85e8b822d86f198103049eaf216d Mon Sep 17 00:00:00 2001 From: husamMousa <88555163+husamMousa@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:44:09 +0300 Subject: [PATCH 1/3] feat(WheelPicker): add WheelPicker and WheelPickerGroup components --- .../WheelPickerCountriesExample.vue | 19 + .../wheel-picker/WheelPickerDateExample.vue | 29 + .../wheel-picker/WheelPickerFormExample.vue | 30 + .../wheel-picker/WheelPickerSlotExample.vue | 22 + .../wheel-picker/WheelPickerTimeExample.vue | 25 + .../content/docs/2.components/wheel-picker.md | 511 ++++++++++ .../nuxt/app/composables/useNavigation.ts | 3 +- .../app/pages/components/wheel-picker.vue | 108 ++ src/runtime/components/WheelPicker.vue | 532 ++++++++++ src/runtime/components/WheelPickerGroup.vue | 98 ++ src/runtime/composables/useWheelPicker.ts | 494 ++++++++++ src/runtime/types/index.ts | 2 + src/runtime/types/theme.ts | 2 + src/theme/index.ts | 2 + src/theme/wheel-picker-group.ts | 31 + src/theme/wheel-picker.ts | 83 ++ test/components/WheelPicker.spec.ts | 236 +++++ test/components/WheelPickerGroup.spec.ts | 51 + .../WheelPicker-vue.spec.ts.snap | 927 ++++++++++++++++++ .../__snapshots__/WheelPicker.spec.ts.snap | 927 ++++++++++++++++++ .../WheelPickerGroup-vue.spec.ts.snap | 57 ++ .../WheelPickerGroup.spec.ts.snap | 57 ++ test/utils/form.ts | 6 +- 23 files changed, 4249 insertions(+), 3 deletions(-) create mode 100644 docs/app/components/content/examples/wheel-picker/WheelPickerCountriesExample.vue create mode 100644 docs/app/components/content/examples/wheel-picker/WheelPickerDateExample.vue create mode 100644 docs/app/components/content/examples/wheel-picker/WheelPickerFormExample.vue create mode 100644 docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue create mode 100644 docs/app/components/content/examples/wheel-picker/WheelPickerTimeExample.vue create mode 100644 docs/content/docs/2.components/wheel-picker.md create mode 100644 playgrounds/nuxt/app/pages/components/wheel-picker.vue create mode 100644 src/runtime/components/WheelPicker.vue create mode 100644 src/runtime/components/WheelPickerGroup.vue create mode 100644 src/runtime/composables/useWheelPicker.ts create mode 100644 src/theme/wheel-picker-group.ts create mode 100644 src/theme/wheel-picker.ts create mode 100644 test/components/WheelPicker.spec.ts create mode 100644 test/components/WheelPickerGroup.spec.ts create mode 100644 test/components/__snapshots__/WheelPicker-vue.spec.ts.snap create mode 100644 test/components/__snapshots__/WheelPicker.spec.ts.snap create mode 100644 test/components/__snapshots__/WheelPickerGroup-vue.spec.ts.snap create mode 100644 test/components/__snapshots__/WheelPickerGroup.spec.ts.snap diff --git a/docs/app/components/content/examples/wheel-picker/WheelPickerCountriesExample.vue b/docs/app/components/content/examples/wheel-picker/WheelPickerCountriesExample.vue new file mode 100644 index 0000000000..ce627f28dd --- /dev/null +++ b/docs/app/components/content/examples/wheel-picker/WheelPickerCountriesExample.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/docs/app/components/content/examples/wheel-picker/WheelPickerDateExample.vue b/docs/app/components/content/examples/wheel-picker/WheelPickerDateExample.vue new file mode 100644 index 0000000000..45f813518c --- /dev/null +++ b/docs/app/components/content/examples/wheel-picker/WheelPickerDateExample.vue @@ -0,0 +1,29 @@ + + + + + + + + + + + + {{ date }} + + + diff --git a/docs/app/components/content/examples/wheel-picker/WheelPickerFormExample.vue b/docs/app/components/content/examples/wheel-picker/WheelPickerFormExample.vue new file mode 100644 index 0000000000..e55f656dc5 --- /dev/null +++ b/docs/app/components/content/examples/wheel-picker/WheelPickerFormExample.vue @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue b/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue new file mode 100644 index 0000000000..cefbb17f54 --- /dev/null +++ b/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue @@ -0,0 +1,22 @@ + + + + + + + {{ item.label }} + {{ (item.raw as any).price }} + + + diff --git a/docs/app/components/content/examples/wheel-picker/WheelPickerTimeExample.vue b/docs/app/components/content/examples/wheel-picker/WheelPickerTimeExample.vue new file mode 100644 index 0000000000..46a0181f22 --- /dev/null +++ b/docs/app/components/content/examples/wheel-picker/WheelPickerTimeExample.vue @@ -0,0 +1,25 @@ + + + + + + + + + + + + {{ time }} + + + diff --git a/docs/content/docs/2.components/wheel-picker.md b/docs/content/docs/2.components/wheel-picker.md new file mode 100644 index 0000000000..5a38d9294e --- /dev/null +++ b/docs/content/docs/2.components/wheel-picker.md @@ -0,0 +1,511 @@ +--- +title: WheelPicker +description: An iOS-style wheel for picking a single value from a list. +category: form +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/v4/src/runtime/components/WheelPicker.vue +navigation.badge: Soon +--- + +## Usage + +Use the `v-model` directive to control the value of the WheelPicker or the `default-value` prop to set the initial value when you do not need to control its state. + +Scroll with the mouse wheel, drag with a pointer or use the keyboard — the selected item always snaps to the center. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' + - 'Saturday' + - 'Sunday' +--- +:: + +### Items + +Use the `items` prop as an array of strings or numbers: + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + modelValue: 5 + items: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 +--- +:: + +You can also pass an array of objects with the following properties: + +- `label?: string`{lang="ts-type"} +- `icon?: string`{lang="ts-type"} +- [`value?: string`{lang="ts-type"}](#value-key) +- `disabled?: boolean`{lang="ts-type"} +- `class?: any`{lang="ts-type"} +- `ui?: { item?: ClassNameValue, itemLeading?: ClassNameValue, itemLabel?: ClassNameValue }`{lang="ts-type"} + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + modelValue: 'par' + items: + - label: 'London' + value: 'lon' + - label: 'Paris' + value: 'par' + - label: 'Tokyo' + value: 'tok' + - label: 'New York' + value: 'nyc' +--- +:: + +### Value Key + +You can change the property that is used to set the value by using the `value-key` prop. Defaults to `value`. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items + - valueKey +external: + - items + - modelValue +props: + modelValue: 2 + valueKey: 'id' + items: + - label: 'London' + id: 1 + - label: 'Paris' + id: 2 + - label: 'Tokyo' + id: 3 +--- +:: + +### Icon + +Use the `icon` property in the items to display an [Icon](/docs/components/icon) inside each option. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + modelValue: 'apple' + items: + - label: 'Apple' + value: 'apple' + icon: 'i-simple-icons-apple' + - label: 'GitHub' + value: 'github' + icon: 'i-simple-icons-github' + - label: 'Google' + value: 'google' + icon: 'i-simple-icons-google' +--- +:: + +### Color + +Use the `color` prop to change the color of the WheelPicker. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + color: neutral + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Variant + +Use the `variant` prop to change the appearance of the selection indicator. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + variant: line + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Size + +Use the `size` prop to change the size of the WheelPicker. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + size: xl + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Orientation + +Use the `orientation` prop to change the direction of the wheel. Defaults to `vertical`. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + orientation: horizontal + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Visible Items + +Use the `visible-items` prop to change how many items are visible at once. Use an odd number to keep the selected item centered. Defaults to `5`. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + visibleItems: 7 + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' + - 'Saturday' + - 'Sunday' +--- +:: + +### Item Height + +Use the `item-height` prop to change the height (or width when horizontal) of each item, in pixels. Defaults to `32`. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + itemHeight: 48 + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Loop + +Use the `loop` prop to make the wheel wrap around infinitely. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + loop: true + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' + - 'Saturday' + - 'Sunday' +--- +:: + +### Sensitivity + +Use the `sensitivity` prop to control how far the wheel travels per wheel-tick or drag. Values above `1` scroll faster, below `1` slower. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + sensitivity: 2 + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Readonly + +Use the `readonly` prop to keep the wheel focusable while preventing its value from changing. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + readonly: true + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Haptics + +Use the `haptics` prop to trigger a short vibration each time the selection changes on supported devices. Motion and momentum automatically respect the user's `prefers-reduced-motion` setting. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + haptics: true + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +### Disabled + +Use the `disabled` prop to disable the WheelPicker. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + disabled: true + modelValue: 'Wednesday' + items: + - 'Monday' + - 'Tuesday' + - 'Wednesday' + - 'Thursday' + - 'Friday' +--- +:: + +You can also disable individual items using the `disabled` property. + +::component-code +--- +prettier: true +ignore: + - modelValue + - items +external: + - items + - modelValue +props: + modelValue: 'par' + items: + - label: 'London' + value: 'lon' + - label: 'Paris' + value: 'par' + - label: 'Tokyo' + value: 'tok' + disabled: true + - label: 'New York' + value: 'nyc' +--- +:: + +## Examples + +### Date picker + +Wrap several `WheelPicker` columns in a `WheelPickerGroup` to build a multi-column picker with a single shared frame and center indicator. The group shares its `item-height`, `visible-items`, `size` and `color` with every column so they stay aligned. + +:component-example{name="wheel-picker-date-example"} + +### Time picker + +The same `WheelPickerGroup` composes hour, minute and period columns — set `loop` on the columns that should wrap. + +:component-example{name="wheel-picker-time-example"} + +### With countries + +:component-example{name="wheel-picker-countries-example"} + +### With custom slot + +Use the `#item` slot to fully customize how each option is rendered. + +:component-example{name="wheel-picker-slot-example"} + +### Within a form + +Use the WheelPicker within a [Form](/docs/components/form) to validate its value. + +:component-example{name="wheel-picker-form-example"} + +## API + +### Props + +:component-props + +### Slots + +:component-slots + +### Emits + +:component-emits + +## Theme + +:component-theme + +## Changelog + +:component-changelog diff --git a/playgrounds/nuxt/app/composables/useNavigation.ts b/playgrounds/nuxt/app/composables/useNavigation.ts index 8c110d7b3f..cf0cfe822a 100644 --- a/playgrounds/nuxt/app/composables/useNavigation.ts +++ b/playgrounds/nuxt/app/composables/useNavigation.ts @@ -91,7 +91,8 @@ const components = [ 'toast', 'tooltip', 'tree', - 'user' + 'user', + 'wheel-picker' ].map(component => ({ label: upperName(component.split('/').pop() as string), icon: 'i-lucide-box', to: `/components/${component}` })) export const useNavigation = () => { diff --git a/playgrounds/nuxt/app/pages/components/wheel-picker.vue b/playgrounds/nuxt/app/pages/components/wheel-picker.vue new file mode 100644 index 0000000000..718e94f4d3 --- /dev/null +++ b/playgrounds/nuxt/app/pages/components/wheel-picker.vue @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + Selected: {{ value }} + + + + + + + + + + + + + + + + + + + diff --git a/src/runtime/components/WheelPicker.vue b/src/runtime/components/WheelPicker.vue new file mode 100644 index 0000000000..f382124c4d --- /dev/null +++ b/src/runtime/components/WheelPicker.vue @@ -0,0 +1,532 @@ + + + + + + + + + + + + + + + + + + + {{ normalizedItems[cell.index]?.label }} + + + + + + + + + + + + {{ props.placeholder }} + + + + + + + diff --git a/src/runtime/components/WheelPickerGroup.vue b/src/runtime/components/WheelPickerGroup.vue new file mode 100644 index 0000000000..ad19b35802 --- /dev/null +++ b/src/runtime/components/WheelPickerGroup.vue @@ -0,0 +1,98 @@ + + + + + + + + + + + + + diff --git a/src/runtime/composables/useWheelPicker.ts b/src/runtime/composables/useWheelPicker.ts new file mode 100644 index 0000000000..b18d413e2c --- /dev/null +++ b/src/runtime/composables/useWheelPicker.ts @@ -0,0 +1,494 @@ +import { ref, computed, toValue, onScopeDispose } from 'vue' +import type { MaybeRefOrGetter, CSSProperties, InjectionKey } from 'vue' + +/** + * Shared configuration provided by `` to the `` + * columns it wraps, so every column aligns to the same geometry and the group + * can draw a single shared frame and center indicator. + */ +export interface WheelPickerGroupContext { + size: () => any + color: () => any + variant: () => any + itemHeight: () => number | undefined + visibleItems: () => number | undefined +} + +export const wheelPickerGroupInjectionKey: InjectionKey = Symbol('nuxt-ui.wheel-picker-group') + +export interface UseWheelPickerOptions { + /** Total number of items in the column. */ + count: MaybeRefOrGetter + /** Height (vertical) or width (horizontal) of a single item, in pixels. */ + itemSize: MaybeRefOrGetter + /** Number of items visible at once (used to size the viewport and the 3D curvature). */ + visibleItems: MaybeRefOrGetter + /** Whether the wheel wraps around infinitely. */ + loop?: MaybeRefOrGetter + /** Disable all interactions. */ + disabled?: MaybeRefOrGetter + /** Prevent value changes while keeping the wheel focusable. */ + readonly?: MaybeRefOrGetter + /** Fire a short haptic vibration each time the active item changes. */ + haptics?: MaybeRefOrGetter + /** Enable inertia/momentum after a drag or flick. */ + momentum?: MaybeRefOrGetter + /** Multiplier applied to wheel and drag movement (1 = default). */ + sensitivity?: MaybeRefOrGetter + /** Snap to the nearest item when scrolling stops. */ + snap?: MaybeRefOrGetter + /** Base duration of the snap animation, in milliseconds. */ + animationDuration?: MaybeRefOrGetter + /** Lay the wheel out horizontally instead of vertically. */ + horizontal?: MaybeRefOrGetter + /** Right-to-left layout (only affects the horizontal orientation). */ + rtl?: MaybeRefOrGetter + /** Called with the resolved item index whenever the active item changes. */ + onChange?: (index: number) => void + /** Called when a scroll interaction begins. */ + onScrollStart?: () => void + /** Called when a scroll interaction ends (after snapping). */ + onScrollEnd?: () => void +} + +const MAX_UNIT_ANGLE = 30 +const WHEEL_SNAP_DELAY = 120 +const MOMENTUM_AMPLITUDE = 90 + +function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3) +} + +/** + * Headless state machine powering a single wheel-picker column. + * + * The public state is a continuous float `position` expressed in item units: + * `position === 2` means item 2 is perfectly centered. Selection is always the + * rounded (and, in loop mode, wrapped) `position`, so it stays correct + * regardless of the 3D projection used to render the cells. Extracting it as a + * composable keeps `WheelPicker.vue` declarative and lets a future multi-column + * picker (date, time, country…) instantiate one engine per column. + */ +export function useWheelPicker(options: UseWheelPickerOptions) { + const position = ref(0) + const isScrolling = ref(false) + + let rafId: number | null = null + let wheelTimer: ReturnType | null = null + + // Pointer drag bookkeeping. + let dragging = false + let pointerId: number | null = null + let startCoord = 0 + let startPosition = 0 + let lastPosition = 0 + let lastTime = 0 + let velocity = 0 + let target: EventTarget | null = null + + const count = () => Math.max(0, Math.trunc(toValue(options.count))) + const itemSize = () => Math.max(1, toValue(options.itemSize)) + const sensitivity = () => { + const value = toValue(options.sensitivity) + return value && value > 0 ? value : 1 + } + const isLoop = () => !!toValue(options.loop) && count() > 1 + const isDisabled = () => !!toValue(options.disabled) + const isReadonly = () => !!toValue(options.readonly) + const isBlocked = () => isDisabled() || isReadonly() + const isHorizontal = () => !!toValue(options.horizontal) + + function prefersReducedMotion(): boolean { + return import.meta.client && typeof matchMedia !== 'undefined' + && matchMedia('(prefers-reduced-motion: reduce)').matches + } + + /** Resolve the snap duration, collapsing to instant when motion is reduced. */ + function baseDuration(): number { + if (prefersReducedMotion()) return 0 + return toValue(options.animationDuration) ?? 200 + } + + function momentumEnabled(): boolean { + return toValue(options.momentum) !== false && !prefersReducedMotion() + } + + /** Short haptic pulse on item change, when enabled and supported. */ + function haptic() { + if (!toValue(options.haptics) || !import.meta.client) return + if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') { + navigator.vibrate(8) + } + } + + function wrap(value: number): number { + const n = count() + if (n <= 0) return 0 + return ((value % n) + n) % n + } + + function clamp(value: number): number { + const n = count() + if (n <= 0) return 0 + return Math.min(Math.max(value, 0), n - 1) + } + + /** Constrain a raw position, with rubber-band resistance past the edges. */ + function resist(value: number): number { + if (isLoop()) return value + const n = count() + if (n <= 0) return 0 + if (value < 0) return value * 0.3 + if (value > n - 1) return (n - 1) + (value - (n - 1)) * 0.3 + return value + } + + const activeIndex = computed(() => { + const n = count() + if (n <= 0) return -1 + const rounded = Math.round(position.value) + return isLoop() ? wrap(rounded) : clamp(rounded) + }) + + function cancelAnimation() { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + } + + function beginScroll() { + if (!isScrolling.value) { + isScrolling.value = true + options.onScrollStart?.() + } + } + + function endScroll() { + if (isScrolling.value) { + isScrolling.value = false + options.onScrollEnd?.() + } + } + + function emitChange(previous: number) { + if (activeIndex.value !== previous && activeIndex.value >= 0) { + haptic() + options.onChange?.(activeIndex.value) + } + } + + /** Animate `position` to a target value using an ease-out curve. */ + function animateTo(to: number, duration: number) { + cancelAnimation() + const from = position.value + const distance = to - from + const startTime = performance.now() + const total = Math.max(1, duration) + let previous = activeIndex.value + + // A no-op move emits nothing. + if (distance === 0) { + settle(to) + return + } + + // Skip the RAF loop for zero-duration moves (keeps `prefers-reduced-motion` + // and unit tests fully synchronous) while still emitting scroll events. + if (duration <= 0) { + beginScroll() + settle(to) + return + } + + beginScroll() + + const step = (now: number) => { + const elapsed = now - startTime + const t = Math.min(1, elapsed / total) + position.value = from + distance * easeOutCubic(t) + emitChange(previous) + previous = activeIndex.value + + if (t < 1) { + rafId = requestAnimationFrame(step) + } else { + rafId = null + settle(to) + } + } + + rafId = requestAnimationFrame(step) + } + + /** Land on a resolved integer index, normalizing any loop overflow. */ + function settle(to: number) { + const previous = activeIndex.value + const resolved = isLoop() ? wrap(Math.round(to)) : clamp(Math.round(to)) + position.value = resolved + emitChange(previous) + endScroll() + } + + const snapEnabled = () => toValue(options.snap) !== false + + function snapToNearest() { + animateTo(Math.round(position.value), baseDuration()) + } + + /** + * Programmatically move to an item. Used by `v-model` sync and keyboard. + */ + function scrollToIndex(index: number, animated = true) { + if (count() <= 0) return + cancelAnimation() + + if (isLoop()) { + // Take the shortest wrapped path from the current position. + const n = count() + const current = position.value + const delta = ((index - wrap(current)) % n + n) % n + const to = current + (delta > n / 2 ? delta - n : delta) + if (animated) { + animateTo(to, baseDuration()) + } else { + settle(to) + } + return + } + + const clamped = clamp(index) + if (animated) { + animateTo(clamped, baseDuration()) + } else { + position.value = clamped + } + } + + /** Relative move by whole items (keyboard arrows / page keys). */ + function move(delta: number) { + if (isBlocked() || count() <= 0) return + const base = Math.round(position.value) + scrollToIndex(isLoop() ? base + delta : clamp(base + delta), true) + } + + function onWheel(event: WheelEvent) { + if (isBlocked() || count() <= 0) return + event.preventDefault() + cancelAnimation() + beginScroll() + + const primary = isHorizontal() + ? (Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY) + : event.deltaY + const direction = isHorizontal() && toValue(options.rtl) ? -1 : 1 + + position.value = resist(position.value + (primary * direction * sensitivity()) / itemSize()) + + if (wheelTimer) clearTimeout(wheelTimer) + wheelTimer = setTimeout(() => { + wheelTimer = null + if (snapEnabled()) { + snapToNearest() + } else { + settle(position.value) + } + }, WHEEL_SNAP_DELAY) + } + + function readCoord(event: PointerEvent): number { + return isHorizontal() ? event.clientX : event.clientY + } + + function onPointerDown(event: PointerEvent) { + if (isBlocked() || count() <= 0 || event.button !== 0) return + + cancelAnimation() + dragging = true + pointerId = event.pointerId + target = event.currentTarget + startCoord = readCoord(event) + startPosition = position.value + lastPosition = position.value + lastTime = performance.now() + velocity = 0 + + const el = event.currentTarget as HTMLElement | null + el?.setPointerCapture?.(event.pointerId) + // `preventDefault` stops text selection during the drag but also suppresses + // the native focus, so focus the wheel explicitly to keep it keyboard-driven. + el?.focus?.() + beginScroll() + event.preventDefault() + } + + function onPointerMove(event: PointerEvent) { + if (!dragging || event.pointerId !== pointerId) return + + const delta = readCoord(event) - startCoord + const direction = isHorizontal() && toValue(options.rtl) ? 1 : -1 + const next = startPosition + (delta * direction * sensitivity()) / itemSize() + position.value = resist(next) + + const now = performance.now() + const dt = now - lastTime + if (dt > 0) { + // Smooth the velocity a little to avoid jitter on the last sample. + velocity = 0.8 * ((position.value - lastPosition) / dt) + 0.2 * velocity + lastPosition = position.value + lastTime = now + } + } + + function onPointerUp(event: PointerEvent) { + if (!dragging || event.pointerId !== pointerId) return + dragging = false + pointerId = null + + if (target && (target as HTMLElement).releasePointerCapture) { + try { + (target as HTMLElement).releasePointerCapture(event.pointerId) + } catch { + // Pointer may already be released. + } + } + target = null + + const projected = momentumEnabled() + ? position.value + velocity * MOMENTUM_AMPLITUDE + : position.value + + const to = snapEnabled() ? Math.round(projected) : projected + const distance = Math.abs(to - position.value) + const base = baseDuration() + const duration = Math.min(base + distance * 80, base * 5) + + animateTo(isLoop() ? to : clamp(to), duration) + } + + function onKeydown(event: KeyboardEvent) { + if (isBlocked() || count() <= 0) return + + const visible = Math.max(1, Math.trunc(toValue(options.visibleItems))) + const horizontal = isHorizontal() + const rtl = toValue(options.rtl) + + switch (event.key) { + case 'ArrowDown': + if (horizontal) return + event.preventDefault() + move(1) + break + case 'ArrowUp': + if (horizontal) return + event.preventDefault() + move(-1) + break + case 'ArrowRight': + if (!horizontal) return + event.preventDefault() + move(rtl ? -1 : 1) + break + case 'ArrowLeft': + if (!horizontal) return + event.preventDefault() + move(rtl ? 1 : -1) + break + case 'PageDown': + event.preventDefault() + move(visible) + break + case 'PageUp': + event.preventDefault() + move(-visible) + break + case 'Home': + event.preventDefault() + scrollToIndex(0, true) + break + case 'End': + event.preventDefault() + scrollToIndex(count() - 1, true) + break + } + } + + /** + * Inline style for a rendered cell. Projects the flat list onto a virtual + * cylinder using a translate + scale (no CSS `perspective`, so there is no + * depth magnification that could overflow/clip the viewport): the centered + * item is full size while neighbours bunch together and flatten toward the + * edges, reproducing the wheel curvature. `virtualIndex` may lie outside + * `[0, count)` in loop mode; the caller maps it back to a real item. + */ + function itemStyle(virtualIndex: number): CSSProperties { + const size = itemSize() + const visible = Math.max(1, toValue(options.visibleItems)) + const half = (visible - 1) / 2 + const unit = Math.min(90 / (half + 1), MAX_UNIT_ANGLE) + const distance = virtualIndex - position.value + const angle = distance * unit + const hidden = Math.abs(angle) >= 90 + + const rad = (angle * Math.PI) / 180 + const unitRad = (unit * Math.PI) / 180 + // Cylinder radius chosen so adjacent items are spaced by ~`size` at the center. + const radius = size / unitRad + const offset = radius * Math.sin(rad) + const scale = Math.max(0, Math.cos(rad)) + const opacity = hidden ? 0 : Math.max(0.2, scale) + const visibility = hidden ? 'hidden' as const : undefined + + const horizontal = isHorizontal() + const direction = horizontal && toValue(options.rtl) ? -1 : 1 + const translate = offset * direction + + if (horizontal) { + // Size to content (not clamped to `size`) so labels are never truncated; + // `translateX(-50%)` keeps each item centered on its position along the axis. + return { + position: 'absolute', + insetBlock: 0, + insetInlineStart: '50%', + transform: `translateX(calc(-50% + ${translate}px)) scaleX(${scale})`, + opacity, + visibility + } + } + + return { + position: 'absolute', + insetInline: 0, + top: '50%', + height: `${size}px`, + marginTop: `-${size / 2}px`, + transform: `translateY(${translate}px) scaleY(${scale})`, + opacity, + visibility + } + } + + onScopeDispose(() => { + cancelAnimation() + if (wheelTimer) clearTimeout(wheelTimer) + }) + + return { + /** Continuous position in item units (item N centered when `position === N`). */ + position, + /** Resolved, wrapped/clamped index of the centered item. */ + activeIndex, + /** Whether a scroll/drag/animation is currently in flight. */ + isScrolling, + scrollToIndex, + move, + itemStyle, + onWheel, + onPointerDown, + onPointerMove, + onPointerUp, + onKeydown + } +} + +export type WheelPickerEngine = ReturnType diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index e894ff7184..033b871cfd 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -115,6 +115,8 @@ export * from '../components/Toaster.vue' export * from '../components/Tooltip.vue' export * from '../components/Tree.vue' export * from '../components/User.vue' +export * from '../components/WheelPicker.vue' +export * from '../components/WheelPickerGroup.vue' export * from '../components/color-mode/ColorModeAvatar.vue' export * from '../components/color-mode/ColorModeButton.vue' export * from '../components/color-mode/ColorModeImage.vue' diff --git a/src/runtime/types/theme.ts b/src/runtime/types/theme.ts index 8af3438c15..37bd09d0fb 100644 --- a/src/runtime/types/theme.ts +++ b/src/runtime/types/theme.ts @@ -177,6 +177,8 @@ export interface ThemeDefaults { tooltip?: Partial tree?: Partial user?: Partial + wheelPicker?: Partial + wheelPickerGroup?: Partial } /** diff --git a/src/theme/index.ts b/src/theme/index.ts index f6b4dc30b8..8351c9430b 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -113,3 +113,5 @@ export { default as toaster } from './toaster' export { default as tooltip } from './tooltip' export { default as tree } from './tree' export { default as user } from './user' +export { default as wheelPicker } from './wheel-picker' +export { default as wheelPickerGroup } from './wheel-picker-group' diff --git a/src/theme/wheel-picker-group.ts b/src/theme/wheel-picker-group.ts new file mode 100644 index 0000000000..86bb96720b --- /dev/null +++ b/src/theme/wheel-picker-group.ts @@ -0,0 +1,31 @@ +import type { ModuleOptions } from '../module' + +export default (options: Required) => ({ + slots: { + root: 'relative isolate inline-flex items-stretch rounded-md bg-default ring ring-inset ring-accented', + content: 'relative flex items-stretch', + indicator: 'absolute inset-x-1 top-1/2 -translate-y-1/2 pointer-events-none rounded-md' + }, + variants: { + color: { + ...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, { + indicator: `bg-${color}/10` + }])), + neutral: { + indicator: 'bg-elevated' + } + }, + variant: { + pill: { + indicator: '' + }, + line: { + indicator: 'inset-x-0 rounded-none bg-transparent! border-y border-default' + } + } + }, + defaultVariants: { + color: 'neutral', + variant: 'pill' + } +}) diff --git a/src/theme/wheel-picker.ts b/src/theme/wheel-picker.ts new file mode 100644 index 0000000000..c211c174fe --- /dev/null +++ b/src/theme/wheel-picker.ts @@ -0,0 +1,83 @@ +import type { ModuleOptions } from '../module' + +export default (options: Required) => ({ + slots: { + root: 'relative isolate inline-flex select-none', + viewport: ['relative w-full overflow-hidden touch-none outline-none rounded-md bg-default ring ring-inset ring-accented focus-visible:ring-2', options.theme.transitions && 'transition-[color,box-shadow]'], + list: 'absolute inset-0 [mask-image:linear-gradient(to_bottom,transparent,#000_25%,#000_75%,transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_25%,#000_75%,transparent)]', + item: ['absolute flex items-center justify-center gap-1.5 px-2 whitespace-nowrap will-change-transform text-dimmed cursor-default', options.theme.transitions && 'transition-colors'], + itemLeading: 'shrink-0 flex items-center', + itemLabel: 'truncate', + indicator: 'absolute inset-x-1 top-1/2 -translate-y-1/2 pointer-events-none rounded-md', + empty: 'absolute inset-0 flex items-center justify-center text-center text-muted' + }, + variants: { + color: { + ...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, { + viewport: `focus-visible:ring-${color}`, + item: `aria-selected:text-${color}`, + indicator: `bg-${color}/10` + }])), + neutral: { + viewport: 'focus-visible:ring-inverted', + item: 'aria-selected:text-highlighted', + indicator: 'bg-elevated' + } + }, + variant: { + pill: { + indicator: '' + }, + line: { + indicator: 'inset-x-0 rounded-none bg-transparent! border-y border-default' + } + }, + size: { + xs: { item: 'text-xs', empty: 'text-xs' }, + sm: { item: 'text-sm', empty: 'text-sm' }, + md: { item: 'text-base', empty: 'text-sm' }, + lg: { item: 'text-lg', empty: 'text-base' }, + xl: { item: 'text-xl', empty: 'text-base' } + }, + orientation: { + vertical: { + root: 'w-36', + item: 'aria-selected:font-medium' + }, + horizontal: { + root: 'w-auto max-w-full', + list: '[mask-image:linear-gradient(to_right,transparent,#000_25%,#000_75%,transparent)] [-webkit-mask-image:linear-gradient(to_right,transparent,#000_25%,#000_75%,transparent)]', + item: 'aria-selected:font-medium' + } + }, + disabled: { + true: { + root: 'opacity-75 cursor-not-allowed', + viewport: 'pointer-events-none' + } + }, + bare: { + true: { + viewport: 'bg-transparent ring-0 rounded-none' + } + } + }, + compoundVariants: [{ + orientation: 'horizontal', + variant: 'pill', + class: { + indicator: 'inset-x-auto inset-y-1 start-1/2 top-auto translate-y-0 -translate-x-1/2 rtl:translate-x-1/2' + } + }, { + orientation: 'horizontal', + variant: 'line', + class: { + indicator: 'inset-x-auto inset-y-0 start-1/2 top-auto translate-y-0 -translate-x-1/2 rtl:translate-x-1/2 border-y-0 border-x border-default' + } + }], + defaultVariants: { + size: 'md', + color: 'neutral', + variant: 'pill' + } +}) diff --git a/test/components/WheelPicker.spec.ts b/test/components/WheelPicker.spec.ts new file mode 100644 index 0000000000..5d9aa937c7 --- /dev/null +++ b/test/components/WheelPicker.spec.ts @@ -0,0 +1,236 @@ +import { reactive } from 'vue' +import { describe, it, expect, test } from 'vitest' +import { axe } from 'vitest-axe' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { flushPromises } from '@vue/test-utils' +import WheelPicker from '../../src/runtime/components/WheelPicker.vue' +import { renderEach } from '../component-render' +import { renderForm } from '../utils/form' +import theme from '#build/ui/wheel-picker' + +describe('WheelPicker', () => { + const sizes = Object.keys(theme.variants.size) as any + const colors = Object.keys(theme.variants.color) as any + const variants = Object.keys(theme.variants.variant) as any + const orientations = Object.keys(theme.variants.orientation) as any + + const items = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'] + const objectItems = [ + { label: 'London', value: 'lon' }, + { label: 'Paris', value: 'par' }, + { label: 'Tokyo', value: 'tok', icon: 'i-lucide-map-pin' }, + { label: 'Berlin', value: 'ber', disabled: true } + ] + + renderEach(WheelPicker, [ + // Props + ['with items', { props: { items } }], + ['with object items', { props: { items: objectItems } }], + ['with modelValue', { props: { items, modelValue: 'Item 3' } }], + ['with defaultValue', { props: { items, defaultValue: 'Item 2' } }], + ['with name', { props: { items, name: 'name' } }], + ['with placeholder', { props: { placeholder: 'No data' } }], + ['with disabled', { props: { items, disabled: true } }], + ['with readonly', { props: { items, readonly: true } }], + ['with haptics', { props: { items, haptics: true } }], + ['with sensitivity', { props: { items, sensitivity: 2 } }], + ['with loop', { props: { items, loop: true } }], + ['with visibleItems', { props: { items, visibleItems: 3 } }], + ['with itemHeight', { props: { items, itemHeight: 48 } }], + ['with ariaLabel', { props: { items, ariaLabel: 'Pick an item' } }], + ...sizes.map((size: string) => [`with size ${size}`, { props: { items, size } }]), + ...colors.map((color: string) => [`with color ${color}`, { props: { items, color, modelValue: 'Item 1' } }]), + ...variants.map((variant: string) => [`with variant ${variant}`, { props: { items, variant } }]), + ...orientations.map((orientation: string) => [`with orientation ${orientation}`, { props: { items, orientation } }]), + ['with as', { props: { items, as: 'section' } }], + ['with class', { props: { items, class: 'w-40' } }], + ['with ui', { props: { items, ui: { item: 'font-bold' } } }], + // Slots + ['with item slot', { props: { items }, slots: { item: ({ item }: any) => `Item: ${item.label}` } }], + ['with selected slot', { props: { items }, slots: { selected: ({ item }: any) => `Selected: ${item.label}` } }], + ['with prefix slot', { props: { items }, slots: { prefix: () => 'Prefix' } }], + ['with suffix slot', { props: { items }, slots: { suffix: () => 'Suffix' } }], + ['with empty slot', { props: { items: [] }, slots: { empty: () => 'Nothing here' } }] + ]) + + it('passes accessibility tests', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { + items, + ariaLabel: 'Accessible wheel', + modelValue: 'Item 2' + } + }) + + expect(await axe(wrapper.element)).toHaveNoViolations() + }) + + describe('accessibility', () => { + test('exposes listbox and option roles', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 1' } + }) + + const listbox = wrapper.find('[role="listbox"]') + expect(listbox.exists()).toBe(true) + expect(listbox.attributes('aria-orientation')).toBe('vertical') + + const selected = wrapper.find('[role="option"][aria-selected="true"]') + expect(selected.exists()).toBe(true) + expect(listbox.attributes('aria-activedescendant')).toBe(selected.attributes('id')) + }) + + test('is focusable when enabled and not focusable when disabled', async () => { + const wrapper = await mountSuspended(WheelPicker, { props: { items } }) + expect(wrapper.find('[role="listbox"]').attributes('tabindex')).toBe('0') + + const disabledWrapper = await mountSuspended(WheelPicker, { props: { items, disabled: true } }) + expect(disabledWrapper.find('[role="listbox"]').attributes('tabindex')).toBe('-1') + }) + }) + + describe('keyboard navigation', () => { + async function mountKeyboard() { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 3', animationDuration: 0 } + }) + return { wrapper, listbox: wrapper.find('[role="listbox"]') } + } + + test('ArrowDown selects the next item', async () => { + const { wrapper, listbox } = await mountKeyboard() + await listbox.trigger('keydown', { key: 'ArrowDown' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Item 4']) + }) + + test('ArrowUp selects the previous item', async () => { + const { wrapper, listbox } = await mountKeyboard() + await listbox.trigger('keydown', { key: 'ArrowUp' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Item 2']) + }) + + test('Home selects the first item', async () => { + const { wrapper, listbox } = await mountKeyboard() + await listbox.trigger('keydown', { key: 'Home' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Item 1']) + }) + + test('End selects the last item', async () => { + const { wrapper, listbox } = await mountKeyboard() + await listbox.trigger('keydown', { key: 'End' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Item 5']) + }) + + test('does not move past the boundaries when not looping', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 1', animationDuration: 0 } + }) + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowUp' }) + expect(wrapper.emitted('update:modelValue')).toBeFalsy() + }) + + test('wraps around the boundaries when looping', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 1', loop: true, animationDuration: 0 } + }) + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowUp' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Item 5']) + }) + + test('does nothing when disabled', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 3', disabled: true, animationDuration: 0 } + }) + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' }) + expect(wrapper.emitted('update:modelValue')).toBeFalsy() + }) + + test('does nothing when readonly but stays focusable', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 3', readonly: true, animationDuration: 0 } + }) + const listbox = wrapper.find('[role="listbox"]') + expect(listbox.attributes('tabindex')).toBe('0') + expect(listbox.attributes('aria-readonly')).toBe('true') + await listbox.trigger('keydown', { key: 'ArrowDown' }) + expect(wrapper.emitted('update:modelValue')).toBeFalsy() + }) + + test('type-ahead jumps to the matching item', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items: objectItems, modelValue: 'lon', animationDuration: 0 } + }) + // Typing "t" should jump to the first item starting with T → Tokyo. + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 't' }) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['tok']) + }) + + test('skips disabled items', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items: objectItems, modelValue: 'tok', animationDuration: 0 } + }) + // 'ber' (index 3) is disabled, so moving down from 'tok' should be blocked. + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' }) + const emitted = wrapper.emitted('update:modelValue') + expect(emitted?.at(-1)).not.toEqual(['ber']) + }) + }) + + describe('emits', () => { + test('change event', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 1', animationDuration: 0 } + }) + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' }) + expect(wrapper.emitted('change')?.at(-1)).toEqual([{ value: 'Item 2', index: 1 }]) + }) + + test('scroll-start and scroll-end events', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 1', animationDuration: 0 } + }) + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' }) + expect(wrapper.emitted('scroll-start')).toBeTruthy() + expect(wrapper.emitted('scroll-end')).toBeTruthy() + }) + }) + + describe('controlled mode', () => { + test('reflects external modelValue changes', async () => { + const wrapper = await mountSuspended(WheelPicker, { + props: { items, modelValue: 'Item 1', animationDuration: 0 } + }) + + await wrapper.setProps({ modelValue: 'Item 4' }) + await flushPromises() + + const selected = wrapper.find('[role="option"][aria-selected="true"]') + expect(selected.text()).toContain('Item 4') + }) + }) + + describe('form integration', () => { + test('validates on change', async () => { + const wrapper = await renderForm({ + state: reactive({ value: 'Item 1' }), + props: { + validateOn: ['change'], + validateOnInputDelay: 0, + async validate(state: any) { + if (state.value !== 'Item 2') return [{ name: 'value', message: 'Error message' }] + return [] + } + }, + slotTemplate: ` + + + + ` + }) + + await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + expect(wrapper.html()).not.toContain('Error message') + }) + }) +}) diff --git a/test/components/WheelPickerGroup.spec.ts b/test/components/WheelPickerGroup.spec.ts new file mode 100644 index 0000000000..7218dd8df1 --- /dev/null +++ b/test/components/WheelPickerGroup.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { axe } from 'vitest-axe' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import WheelPickerGroup from '../../src/runtime/components/WheelPickerGroup.vue' +import WheelPicker from '../../src/runtime/components/WheelPicker.vue' +import { renderEach } from '../component-render' + +describe('WheelPickerGroup', () => { + renderEach(WheelPickerGroup, [ + ['with default', { props: {} }], + ['with color', { props: { color: 'primary' } }], + ['with variant line', { props: { variant: 'line' } }], + ['with itemHeight', { props: { itemHeight: 44 } }], + ['with visibleItems', { props: { visibleItems: 7 } }], + ['with class', { props: { class: 'shadow' } }], + ['with ui', { props: { ui: { indicator: 'bg-primary/20' } } }], + ['with default slot', { slots: { default: () => 'Columns' } }] + ]) + + async function mountGroup(groupProps = {}) { + return mountSuspended({ + components: { WheelPickerGroup, WheelPicker }, + setup() { + return { groupProps } + }, + template: ` + + + + + ` + }) + } + + it('renders one shared indicator and no per-column indicators', async () => { + const wrapper = await mountGroup() + + // The group draws the single center indicator. + const groupIndicators = wrapper.findAll('[data-slot="root"] > [data-slot="indicator"]') + expect(groupIndicators.length).toBe(1) + + // Columns are "bare" — they must not render their own indicator. + expect(wrapper.findAll('[role="listbox"] [data-slot="indicator"]').length).toBe(0) + expect(wrapper.findAll('[role="listbox"]').length).toBe(2) + }) + + it('passes accessibility tests', async () => { + const wrapper = await mountGroup() + expect(await axe(wrapper.element)).toHaveNoViolations() + }) +}) diff --git a/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap b/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap new file mode 100644 index 0000000000..8582acac2a --- /dev/null +++ b/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap @@ -0,0 +1,927 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WheelPicker > renders with ariaLabel correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with as correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with class correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color error correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color info correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color neutral correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color primary correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color secondary correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color success correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color warning correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with defaultValue correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with disabled correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with empty slot correctly 1`] = ` +" + + + Nothing here + +" +`; + +exports[`WheelPicker > renders with haptics correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with item slot correctly 1`] = ` +" + + + + Item: Item 1 + Item: Item 2 + Item: Item 3 + Item: Item 4 + Item: Item 5 + + +" +`; + +exports[`WheelPicker > renders with itemHeight correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with items correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with loop correctly 1`] = ` +" + + + + + Item 5 + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 1 + + + Item 2 + + + +" +`; + +exports[`WheelPicker > renders with modelValue correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with name correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with object items correctly 1`] = ` +" + + + + + London + + + Paris + + Tokyo + + Berlin + + + +" +`; + +exports[`WheelPicker > renders with orientation horizontal correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with orientation vertical correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with placeholder correctly 1`] = ` +" + + + No data + +" +`; + +exports[`WheelPicker > renders with prefix slot correctly 1`] = ` +"Prefix + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with readonly correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with selected slot correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + Selected: Item 1 + +" +`; + +exports[`WheelPicker > renders with sensitivity correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size lg correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size md correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size sm correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size xl correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size xs correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with suffix slot correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Suffix +" +`; + +exports[`WheelPicker > renders with ui correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with variant line correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with variant pill correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with visibleItems correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; diff --git a/test/components/__snapshots__/WheelPicker.spec.ts.snap b/test/components/__snapshots__/WheelPicker.spec.ts.snap new file mode 100644 index 0000000000..7ced5ae16b --- /dev/null +++ b/test/components/__snapshots__/WheelPicker.spec.ts.snap @@ -0,0 +1,927 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WheelPicker > renders with ariaLabel correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with as correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with class correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color error correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color info correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color neutral correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color primary correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color secondary correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color success correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with color warning correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with defaultValue correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with disabled correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with empty slot correctly 1`] = ` +" + + + Nothing here + +" +`; + +exports[`WheelPicker > renders with haptics correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with item slot correctly 1`] = ` +" + + + + Item: Item 1 + Item: Item 2 + Item: Item 3 + Item: Item 4 + Item: Item 5 + + +" +`; + +exports[`WheelPicker > renders with itemHeight correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with items correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with loop correctly 1`] = ` +" + + + + + Item 5 + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 1 + + + Item 2 + + + +" +`; + +exports[`WheelPicker > renders with modelValue correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with name correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with object items correctly 1`] = ` +" + + + + + London + + + Paris + + Tokyo + + Berlin + + + +" +`; + +exports[`WheelPicker > renders with orientation horizontal correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with orientation vertical correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with placeholder correctly 1`] = ` +" + + + No data + +" +`; + +exports[`WheelPicker > renders with prefix slot correctly 1`] = ` +"Prefix + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with readonly correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with selected slot correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + Selected: Item 1 + +" +`; + +exports[`WheelPicker > renders with sensitivity correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size lg correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size md correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size sm correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size xl correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with size xs correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with suffix slot correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Suffix +" +`; + +exports[`WheelPicker > renders with ui correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with variant line correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with variant pill correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; + +exports[`WheelPicker > renders with visibleItems correctly 1`] = ` +" + + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + +" +`; diff --git a/test/components/__snapshots__/WheelPickerGroup-vue.spec.ts.snap b/test/components/__snapshots__/WheelPickerGroup-vue.spec.ts.snap new file mode 100644 index 0000000000..ddfa59cb01 --- /dev/null +++ b/test/components/__snapshots__/WheelPickerGroup-vue.spec.ts.snap @@ -0,0 +1,57 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WheelPickerGroup > renders with class correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with color correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with default correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with default slot correctly 1`] = ` +" + + Columns +" +`; + +exports[`WheelPickerGroup > renders with itemHeight correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with ui correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with variant line correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with visibleItems correctly 1`] = ` +" + + +" +`; diff --git a/test/components/__snapshots__/WheelPickerGroup.spec.ts.snap b/test/components/__snapshots__/WheelPickerGroup.spec.ts.snap new file mode 100644 index 0000000000..ddfa59cb01 --- /dev/null +++ b/test/components/__snapshots__/WheelPickerGroup.spec.ts.snap @@ -0,0 +1,57 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WheelPickerGroup > renders with class correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with color correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with default correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with default slot correctly 1`] = ` +" + + Columns +" +`; + +exports[`WheelPickerGroup > renders with itemHeight correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with ui correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with variant line correctly 1`] = ` +" + + +" +`; + +exports[`WheelPickerGroup > renders with visibleItems correctly 1`] = ` +" + + +" +`; diff --git a/test/utils/form.ts b/test/utils/form.ts index 85c591ee01..f311908c45 100644 --- a/test/utils/form.ts +++ b/test/utils/form.ts @@ -19,7 +19,8 @@ import { UCheckboxGroup, UFileUpload, UInputRating, - UListbox + UListbox, + UWheelPicker } from '#components' export async function renderForm(options: { @@ -67,7 +68,8 @@ export async function renderForm(options: { UCheckboxGroup, UFileUpload, UInputRating, - UListbox + UListbox, + UWheelPicker }, template: options.slotTemplate } From 72cd8c6cc8e71a40485613fff224470bda025387 Mon Sep 17 00:00:00 2001 From: husamMousa <88555163+husamMousa@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:28:27 +0300 Subject: [PATCH 2/3] docs(WheelPicker): simplify custom slot example to avoid type casts --- .../examples/wheel-picker/WheelPickerSlotExample.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue b/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue index cefbb17f54..71c99bc332 100644 --- a/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue +++ b/docs/app/components/content/examples/wheel-picker/WheelPickerSlotExample.vue @@ -9,14 +9,16 @@ const items = ref([ ] satisfies WheelPickerItem[]) const value = ref('pro') + +const prices = computed(() => Object.fromEntries(items.value.map(item => [item.value, item.price]))) - + {{ item.label }} - {{ (item.raw as any).price }} + {{ prices[String(item.value)] }} From 47d25e3103329fab0076ddd87d7b9738ae2d5678 Mon Sep 17 00:00:00 2001 From: husamMousa <88555163+husamMousa@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:41:36 +0300 Subject: [PATCH 3/3] fix(WheelPicker): use nullish fallback for disabled form-field ref Satisfies the nuxt-ui/no-unresolved-form-field-refs lint rule so and app.config disabled defaults are not dropped. --- src/runtime/components/WheelPicker.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/components/WheelPicker.vue b/src/runtime/components/WheelPicker.vue index f382124c4d..c1efd17fe2 100644 --- a/src/runtime/components/WheelPicker.vue +++ b/src/runtime/components/WheelPicker.vue @@ -198,7 +198,7 @@ const group = inject(wheelPickerGroupInjectionKey, undefined) const bare = computed(() => !!group) // eslint-disable-next-line vue/no-dupe-keys -const disabled = computed(() => !!(formFieldDisabled.value || props.disabled)) +const disabled = computed(() => !!(formFieldDisabled.value ?? props.disabled)) const isHorizontal = computed(() => props.orientation === 'horizontal') const isRtl = computed(() => dir.value === 'rtl')
+ {{ date }} +
+ {{ time }} +