"
+`;
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/6] 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/6] 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')
From 20d7cc5206a18a9fce10b2960e9d49592957c195 Mon Sep 17 00:00:00 2001
From: husamMousa <88555163+husamMousa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 09:54:16 +0300
Subject: [PATCH 4/6] fix(WheelPicker): address review feedback
- watch normalizedItems (not count) so reorders/replacements re-center
- wrap nearestEnabled in loop mode to reach enabled items across the boundary
- use unique cell ids by virtual index and fix aria-activedescendant in loop
- guard animateTo with a generation token against superseded RAF steps
- skip change emission on non-animated loop jumps
- normalize wheel deltaMode (line/page) to pixels
- wire emitFormBlur to the viewport for blur validation
---
src/runtime/components/WheelPicker.vue | 36 +-
src/runtime/composables/useWheelPicker.ts | 30 +-
test/components/WheelPicker.spec.ts | 11 +-
.../WheelPicker-vue.spec.ts.snap | 446 +++++++++---------
.../__snapshots__/WheelPicker.spec.ts.snap | 446 +++++++++---------
5 files changed, 509 insertions(+), 460 deletions(-)
diff --git a/src/runtime/components/WheelPicker.vue b/src/runtime/components/WheelPicker.vue
index c1efd17fe2..8263f8dc00 100644
--- a/src/runtime/components/WheelPicker.vue
+++ b/src/runtime/components/WheelPicker.vue
@@ -187,7 +187,7 @@ const { dir } = useLocale()
// eslint-disable-next-line vue/no-dupe-keys
const modelValue = useVModel, 'modelValue', 'update:modelValue'>(props, 'modelValue', emits, { defaultValue: props.defaultValue })
-const { id: _id, name, size: formFieldSize, color, disabled: formFieldDisabled, ariaAttrs, emitFormChange, emitFormInput } = useFormField>(_props, { bind: false })
+const { id: _id, name, size: formFieldSize, color, disabled: formFieldDisabled, ariaAttrs, emitFormBlur, emitFormChange, emitFormInput } = useFormField>(_props, { bind: false })
const fallbackId = useId()
// eslint-disable-next-line vue/no-dupe-keys
const id = computed(() => _id.value ?? fallbackId)
@@ -265,10 +265,18 @@ function indexOfValue(value: WheelPickerValue | undefined): number {
function nearestEnabled(index: number): number {
const items = normalizedItems.value
+ const n = items.length
+ if (n === 0) return -1
if (!items[index]?.disabled) return index
- for (let offset = 1; offset < items.length; offset++) {
- if (!items[index + offset]?.disabled && items[index + offset]) return index + offset
- if (!items[index - offset]?.disabled && items[index - offset]) return index - offset
+
+ const loop = props.loop
+ for (let offset = 1; offset < n; offset++) {
+ // In loop mode the search wraps so it can reach enabled items at the
+ // opposite end of the list.
+ const forward = loop ? (index + offset) % n : index + offset
+ const backward = loop ? ((index - offset) % n + n) % n : index - offset
+ if (items[forward] && !items[forward].disabled) return forward
+ if (items[backward] && !items[backward].disabled) return backward
}
return -1
}
@@ -323,8 +331,10 @@ watch(modelValue, (value) => {
}
})
-// Re-center if the item list changes underneath the current value.
-watch(count, () => {
+// Re-center if the items change underneath the current value. Watching the
+// normalized items (not just the count) also catches reordering or replacing
+// items with another array of the same length.
+watch(normalizedItems, () => {
const index = indexOfValue(modelValue.value as WheelPickerValue)
if (index !== -1 && index !== activeIndex.value) {
engine.scrollToIndex(index, false)
@@ -404,6 +414,15 @@ const cells = computed(() => {
const selectedItem = computed(() => normalizedItems.value[activeIndex.value])
+// Each rendered cell needs a unique DOM id: in loop mode several cells map to
+// the same item, so we key the id by the (unique) virtual index. The centered
+// cell — the one whose virtual index rounds the current position — is what
+// `aria-activedescendant` must point to.
+function cellId(virtualIndex: number) {
+ return `${id.value}-cell-${virtualIndex}`
+}
+const activeDescendant = computed(() => count.value > 0 ? cellId(Math.round(engine.position.value)) : undefined)
+
// In horizontal orientation items are sized to their content, so measure the
// widest item (to drive the pitch) and the active item (to size the highlight).
function measure() {
@@ -454,7 +473,7 @@ defineExpose({
role="listbox"
:aria-label="props.ariaLabel || name"
:aria-orientation="props.orientation"
- :aria-activedescendant="selectedItem?.id"
+ :aria-activedescendant="activeDescendant"
:aria-disabled="disabled || undefined"
:aria-readonly="props.readonly || undefined"
:tabindex="disabled ? -1 : 0"
@@ -468,6 +487,7 @@ defineExpose({
@pointerup="engine.onPointerUp"
@pointercancel="engine.onPointerUp"
@keydown="onKeydown"
+ @blur="emitFormBlur"
>
| null = null
+ // Bumped whenever an animation is cancelled/superseded so a stale RAF step
+ // (e.g. one revived by an onChange → scrollToIndex re-entry) bails out instead
+ // of overwriting `rafId` for the newer animation.
+ let animationGeneration = 0
// Pointer drag bookkeeping.
let dragging = false
@@ -151,6 +157,7 @@ export function useWheelPicker(options: UseWheelPickerOptions) {
})
function cancelAnimation() {
+ animationGeneration++
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
@@ -181,6 +188,7 @@ export function useWheelPicker(options: UseWheelPickerOptions) {
/** Animate `position` to a target value using an ease-out curve. */
function animateTo(to: number, duration: number) {
cancelAnimation()
+ const generation = animationGeneration
const from = position.value
const distance = to - from
const startTime = performance.now()
@@ -204,6 +212,9 @@ export function useWheelPicker(options: UseWheelPickerOptions) {
beginScroll()
const step = (now: number) => {
+ // Bail if a newer animation superseded this one.
+ if (generation !== animationGeneration) return
+
const elapsed = now - startTime
const t = Math.min(1, elapsed / total)
position.value = from + distance * easeOutCubic(t)
@@ -252,7 +263,9 @@ export function useWheelPicker(options: UseWheelPickerOptions) {
if (animated) {
animateTo(to, baseDuration())
} else {
- settle(to)
+ // Non-animated jumps (v-model / mount sync) must not emit `change`,
+ // matching the non-loop path below. `activeIndex` wraps `position`.
+ position.value = to
}
return
}
@@ -278,9 +291,20 @@ export function useWheelPicker(options: UseWheelPickerOptions) {
cancelAnimation()
beginScroll()
+ // Normalize wheel deltas to pixels: Firefox reports line (deltaMode 1) or
+ // page (deltaMode 2) units, which would otherwise be far too small.
+ const axisSize = Math.max(1, toValue(options.visibleItems)) * itemSize()
+ const toPixels = (delta: number) => event.deltaMode === 1
+ ? delta * WHEEL_LINE_HEIGHT
+ : event.deltaMode === 2
+ ? delta * axisSize
+ : delta
+
+ const deltaX = toPixels(event.deltaX)
+ const deltaY = toPixels(event.deltaY)
const primary = isHorizontal()
- ? (Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY)
- : event.deltaY
+ ? (Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY)
+ : deltaY
const direction = isHorizontal() && toValue(options.rtl) ? -1 : 1
position.value = resist(position.value + (primary * direction * sensitivity()) / itemSize())
diff --git a/test/components/WheelPicker.spec.ts b/test/components/WheelPicker.spec.ts
index 5d9aa937c7..9e3a01bd15 100644
--- a/test/components/WheelPicker.spec.ts
+++ b/test/components/WheelPicker.spec.ts
@@ -169,10 +169,10 @@ describe('WheelPicker', () => {
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.
+ // 'ber' (last, disabled) is the only item below 'tok', so ArrowDown must
+ // not change the value at all — it snaps back to the current enabled item.
await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' })
- const emitted = wrapper.emitted('update:modelValue')
- expect(emitted?.at(-1)).not.toEqual(['ber'])
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
})
})
@@ -231,6 +231,11 @@ describe('WheelPicker', () => {
await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(wrapper.html()).not.toContain('Error message')
+
+ // Moving back to an invalid value surfaces the error.
+ await wrapper.find('[role="listbox"]').trigger('keydown', { key: 'ArrowUp' })
+ await flushPromises()
+ expect(wrapper.html()).toContain('Error message')
})
})
})
diff --git a/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap b/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap
index 8582acac2a..9e907e437e 100644
--- a/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap
+++ b/test/components/__snapshots__/WheelPicker-vue.spec.ts.snap
@@ -2,22 +2,22 @@
exports[`WheelPicker > renders with ariaLabel correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -27,22 +27,22 @@ exports[`WheelPicker > renders with ariaLabel correctly 1`] = `
exports[`WheelPicker > renders with as correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -52,22 +52,22 @@ exports[`WheelPicker > renders with as correctly 1`] = `
exports[`WheelPicker > renders with class correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -77,22 +77,22 @@ exports[`WheelPicker > renders with class correctly 1`] = `
exports[`WheelPicker > renders with color error correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -102,22 +102,22 @@ exports[`WheelPicker > renders with color error correctly 1`] = `
exports[`WheelPicker > renders with color info correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -127,22 +127,22 @@ exports[`WheelPicker > renders with color info correctly 1`] = `
exports[`WheelPicker > renders with color neutral correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -152,22 +152,22 @@ exports[`WheelPicker > renders with color neutral correctly 1`] = `
exports[`WheelPicker > renders with color primary correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -177,22 +177,22 @@ exports[`WheelPicker > renders with color primary correctly 1`] = `
exports[`WheelPicker > renders with color secondary correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -202,22 +202,22 @@ exports[`WheelPicker > renders with color secondary correctly 1`] = `
exports[`WheelPicker > renders with color success correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -227,22 +227,22 @@ exports[`WheelPicker > renders with color success correctly 1`] = `
exports[`WheelPicker > renders with color warning correctly 1`] = `
"
-
+
-
+
Item 1
-
+
Item 2
-
+
Item 3
-
+
Item 4
-
+
Item 5
@@ -252,22 +252,22 @@ exports[`WheelPicker > renders with color warning correctly 1`] = `
exports[`WheelPicker > renders with defaultValue correctly 1`] = `
"