diff --git a/packages/vuetify/src/components/VTimePicker/VTimePicker.tsx b/packages/vuetify/src/components/VTimePicker/VTimePicker.tsx index 03de6cbc187..c0fa1643b09 100644 --- a/packages/vuetify/src/components/VTimePicker/VTimePicker.tsx +++ b/packages/vuetify/src/components/VTimePicker/VTimePicker.tsx @@ -15,7 +15,7 @@ import { useProxiedModel } from '@/composables/proxiedModel' import { computed, onMounted, ref, toRef, watch } from 'vue' import { makeTimeValidationProps, useTimeValidation } from './useTimeValidation' import { convert12to24, convert24to12, pad } from './util' -import { genericComponent, omit, propsFactory, useRender } from '@/util' +import { clamp, genericComponent, omit, propsFactory, useRender } from '@/util' // Types import type { PropType } from 'vue' @@ -176,6 +176,16 @@ export const VTimePicker = genericComponent()({ } } + function onClockKeydown (e: KeyboardEvent) { + if (props.disabled) return + if (['ArrowLeft', 'ArrowRight'].includes(e.key)) { + e.preventDefault() + const modes: VTimePickerViewMode[] = ['hour', 'minute', ...(props.useSeconds ? ['second' as const] : [])] + const delta = e.key === 'ArrowRight' ? 1 : -1 + viewMode.value = modes[clamp(modes.indexOf(viewMode.value) + delta, 0, modes.length - 1)] + } + } + function onChange (value: number) { switch (viewMode.value || 'hour') { case 'hour': @@ -279,8 +289,10 @@ export const VTimePicker = genericComponent()({ ? inputMinute.value as number : inputSecond.value as number) } + viewMode={ viewMode.value } onChange={ onChange } onInput={ onInput } + onKeydown={ onClockKeydown } ref={ clockRef } /> ), diff --git a/packages/vuetify/src/components/VTimePicker/VTimePickerClock.sass b/packages/vuetify/src/components/VTimePicker/VTimePickerClock.sass index 7d1b4410424..d209b5fe8c1 100644 --- a/packages/vuetify/src/components/VTimePicker/VTimePickerClock.sass +++ b/packages/vuetify/src/components/VTimePicker/VTimePickerClock.sass @@ -10,10 +10,6 @@ &:after color: rgb(var(--v-theme-primary)) - .v-time-picker-clock__item--active - background-color: rgb(var(--v-theme-surface-variant)) - color: rgb(var(--v-theme-on-surface-variant)) - .v-time-picker-clock margin: $time-picker-padding background: rgb(var(--v-theme-surface-light)) @@ -74,8 +70,9 @@ &--readonly pointer-events: none - .v-time-picker-clock__item--disabled - opacity: var(--v-disabled-opacity) + &:focus-visible + outline-color: rgba(var(--v-theme-on-surface), var(--v-focus-opacity)) + background: rgba(var(--v-theme-on-surface), var(--v-focus-opacity)) .v-picker--full-width .v-time-picker-clock__container @@ -90,42 +87,23 @@ .v-time-picker-clock__item align-items: center - border-radius: 100% + border-radius: 50% !important cursor: default display: flex - font-size: $time-picker-number-font-size + font-size: $time-picker-number-font-size !important justify-content: center - height: $time-picker-indicator-size - position: absolute + position: absolute !important text-align: center - width: $time-picker-indicator-size + height: $time-picker-indicator-size !important + width: $time-picker-indicator-size !important + min-width: $time-picker-indicator-size !important user-select: none transform: translate(-50%, -50%) - - > span - z-index: 1 - - &:before, &:after - content: '' - border-radius: 100% - position: absolute - top: 50% - left: 50% - height: 14px - width: 14px - transform: translate(-50%, -50%) - - &:after, &:before - height: $time-picker-indicator-size - width: $time-picker-indicator-size + z-index: 1 &--active - cursor: default z-index: 2 - &--disabled - pointer-events: none - .v-picker--landscape .v-time-picker-clock &__container @@ -141,5 +119,6 @@ &:after background-color: highlight - &__item--active - outline: 2px solid highlight + &__item--active.v-btn.v-btn + border-width: $time-picker-clock-end-border-width + border-color: highlight diff --git a/packages/vuetify/src/components/VTimePicker/VTimePickerClock.tsx b/packages/vuetify/src/components/VTimePicker/VTimePickerClock.tsx index 9bb409c4edb..d6f9c0ec859 100644 --- a/packages/vuetify/src/components/VTimePicker/VTimePickerClock.tsx +++ b/packages/vuetify/src/components/VTimePicker/VTimePickerClock.tsx @@ -1,8 +1,12 @@ // Styles import './VTimePickerClock.sass' +// Components +import { VBtn } from '@/components/VBtn' + // Composables -import { useBackgroundColor, useTextColor } from '@/composables/color' +import { useTextColor } from '@/composables/color' +import { useLocale } from '@/composables/locale' // Utilities import { computed, onScopeDispose, ref, watch } from 'vue' @@ -10,6 +14,7 @@ import { debounce, genericComponent, IN_BROWSER, propsFactory, useRender } from // Types import type { PropType } from 'vue' +import type { VTimePickerViewMode } from './shared' interface Point { x: number y: number @@ -18,7 +23,10 @@ interface Point { export const makeVTimePickerClockProps = propsFactory({ allowedValues: Function as PropType<(value: number) => boolean>, ampm: Boolean, - color: String, + color: { + type: String, + default: 'surface-variant', + }, disabled: Boolean, displayedValue: null, double: Boolean, @@ -47,6 +55,10 @@ export const makeVTimePickerClockProps = propsFactory({ modelValue: { type: Number, }, + viewMode: { + type: String as PropType, + default: 'hour', + }, }, 'VTimePickerClock') export const VTimePickerClock = genericComponent()({ @@ -60,6 +72,7 @@ export const VTimePickerClock = genericComponent()({ }, setup (props, { emit }) { + const { t } = useLocale() const clockRef = ref(null) const innerClockRef = ref(null) const inputValue = ref(undefined) @@ -69,7 +82,6 @@ export const VTimePickerClock = genericComponent()({ const emitChangeDebounced = debounce((value: number) => emit('change', value), 750) const { textColorClasses, textColorStyles } = useTextColor(() => props.color) - const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(() => props.color) const count = computed(() => props.max - props.min + 1) const roundCount = computed(() => props.double ? (count.value / 2) : count.value) @@ -234,9 +246,52 @@ export const VTimePickerClock = genericComponent()({ onScopeDispose(removeListeners) + function findNextAllowed (current: number, delta: number): number { + let value = current + const maxIterations = count.value + for (let i = 0; i < maxIterations; i++) { + value = ((value - props.min + delta + count.value) % count.value) + props.min + if (isAllowed(value)) return value + } + return current + } + + function onKeydown (e: KeyboardEvent) { + if (props.disabled || props.readonly) return + + let newValue: number | null = null + const current = displayedValue.value + + switch (e.key) { + case 'ArrowUp': + newValue = findNextAllowed(current, 1) + break + case 'ArrowDown': + newValue = findNextAllowed(current, -1) + break + case 'Enter': + e.preventDefault() + emit('change', current) + return + } + + if (newValue !== null && newValue !== current) { + e.preventDefault() + update(newValue) + } + } + useRender(() => { return (
{ const isActive = value === displayedValue.value + const isDisabled = props.disabled || !isAllowed(value) return ( - + { props.format(value) } + ) }) } diff --git a/packages/vuetify/src/components/VTimePicker/__tests__/VTimePicker.spec.browser.tsx b/packages/vuetify/src/components/VTimePicker/__tests__/VTimePicker.spec.browser.tsx index f35086161fe..cb12cf9b51a 100644 --- a/packages/vuetify/src/components/VTimePicker/__tests__/VTimePicker.spec.browser.tsx +++ b/packages/vuetify/src/components/VTimePicker/__tests__/VTimePicker.spec.browser.tsx @@ -6,6 +6,54 @@ import { render, screen, userEvent } from '@test' import { ref } from 'vue' describe('VTimePicker', () => { + describe('clock keyboard', () => { + it.each([ + ['ArrowRight', 'hour', false, 'minute'], + ['ArrowRight', 'minute', true, 'second'], + ['ArrowLeft', 'minute', false, 'hour'], + ['ArrowLeft', 'second', true, 'minute'], + ] as const)('shifts viewMode on %s from %s (useSeconds=%s)', async (key, viewMode, useSeconds, expectedMode) => { + const onUpdateViewMode = vi.fn() + render(() => ( + + )) + + const clock = screen.getByRole('spinbutton') + clock.focus() + await userEvent.keyboard(`{${key}}`) + + expect(onUpdateViewMode).toHaveBeenCalledWith(expectedMode) + }) + + it.each([ + ['ArrowLeft', 'hour', false], + ['ArrowLeft', 'hour', true], + ['ArrowRight', 'minute', false], + ['ArrowRight', 'second', true], + ] as const)('clamps viewMode on %s at %s (useSeconds=%s)', async (key, viewMode, useSeconds) => { + const onUpdateViewMode = vi.fn() + render(() => ( + + )) + + const clock = screen.getByRole('spinbutton') + clock.focus() + await userEvent.keyboard(`{${key}}`) + + expect(onUpdateViewMode).not.toHaveBeenCalled() + }) + }) + describe('variant input', () => { it('constrains typing to emit valid time', async () => { const model = ref(null) diff --git a/packages/vuetify/src/components/VTimePicker/__tests__/VTimePickerClock.spec.browser.ts b/packages/vuetify/src/components/VTimePicker/__tests__/VTimePickerClock.spec.browser.ts new file mode 100644 index 00000000000..1564d0a3ea0 --- /dev/null +++ b/packages/vuetify/src/components/VTimePicker/__tests__/VTimePickerClock.spec.browser.ts @@ -0,0 +1,131 @@ +// Components +import { VTimePickerClock } from '../VTimePickerClock' + +// Utilities +import { render, screen, userEvent } from '@test' + +describe('VTimePickerClock', () => { + describe('keyboard navigation', () => { + it.each([ + ['ArrowUp', 6], + ['ArrowDown', 4], + ])('should update value on %s', async (key, expectedValue) => { + const onInput = vi.fn() + const onChange = vi.fn() + + render(VTimePickerClock, { + props: { + min: 0, + max: 11, + modelValue: 5, + viewMode: 'hour', + onInput, + onChange, + }, + }) + + const clock = screen.getByRole('spinbutton') + clock.focus() + await userEvent.keyboard(`{${key}}`) + + expect(onInput).toHaveBeenCalledWith(expectedValue) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should emit change on Enter to confirm selection', async () => { + const onInput = vi.fn() + const onChange = vi.fn() + + render(VTimePickerClock, { + props: { + min: 0, + max: 11, + modelValue: 5, + viewMode: 'hour', + onInput, + onChange, + }, + }) + + const clock = screen.getByRole('spinbutton') + clock.focus() + await userEvent.keyboard('{Enter}') + + expect(onInput).not.toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith(5) + }) + + it.each([ + ['max to min', 11, '{ArrowUp}', 0], + ['min to max', 0, '{ArrowDown}', 11], + ])('should wrap around from %s', async (_, modelValue, key, expectedValue) => { + const onInput = vi.fn() + + render(VTimePickerClock, { + props: { + min: 0, + max: 11, + modelValue, + viewMode: 'hour', + onInput, + }, + }) + + const clock = screen.getByRole('spinbutton') + clock.focus() + await userEvent.keyboard(key) + + expect(onInput).toHaveBeenCalledWith(expectedValue) + }) + + it('should skip disallowed values', async () => { + const onInput = vi.fn() + const allowedValues = (v: number) => v !== 6 + + render(VTimePickerClock, { + props: { + min: 0, + max: 11, + modelValue: 5, + viewMode: 'hour', + allowedValues, + onInput, + }, + }) + + const clock = screen.getByRole('spinbutton') + clock.focus() + await userEvent.keyboard('{ArrowUp}') + + // Should skip 6 and go to 7 + expect(onInput).toHaveBeenCalledWith(7) + }) + + it.each([ + ['disabled', { disabled: true }], + ['readonly', { readonly: true }], + ])('should not respond when %s', async (_, extraProps) => { + const onInput = vi.fn() + const onChange = vi.fn() + + render(VTimePickerClock, { + props: { + min: 0, + max: 11, + modelValue: 5, + viewMode: 'hour', + ...extraProps, + onInput, + onChange, + }, + }) + + const clock = screen.getByRole('spinbutton') + clock.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + clock.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + + expect(onInput).not.toHaveBeenCalled() + expect(onChange).not.toHaveBeenCalled() + }) + }) +})