Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/vuetify/src/components/VTimePicker/VTimePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -176,6 +176,16 @@ export const VTimePicker = genericComponent<VTimePickerSlots>()({
}
}

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':
Expand Down Expand Up @@ -279,8 +289,10 @@ export const VTimePicker = genericComponent<VTimePickerSlots>()({
? inputMinute.value as number
: inputSecond.value as number)
}
viewMode={ viewMode.value }
onChange={ onChange }
onInput={ onInput }
onKeydown={ onClockKeydown }
ref={ clockRef }
/>
),
Expand Down
47 changes: 13 additions & 34 deletions packages/vuetify/src/components/VTimePicker/VTimePickerClock.sass
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
85 changes: 72 additions & 13 deletions packages/vuetify/src/components/VTimePicker/VTimePickerClock.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
// 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'
import { debounce, genericComponent, IN_BROWSER, propsFactory, useRender } from '@/util'

// Types
import type { PropType } from 'vue'
import type { VTimePickerViewMode } from './shared'
interface Point {
x: number
y: number
Expand All @@ -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,
Expand Down Expand Up @@ -47,6 +55,10 @@ export const makeVTimePickerClockProps = propsFactory({
modelValue: {
type: Number,
},
viewMode: {
type: String as PropType<VTimePickerViewMode>,
default: 'hour',
},
}, 'VTimePickerClock')

export const VTimePickerClock = genericComponent()({
Expand All @@ -60,6 +72,7 @@ export const VTimePickerClock = genericComponent()({
},

setup (props, { emit }) {
const { t } = useLocale()
const clockRef = ref<HTMLElement | null>(null)
const innerClockRef = ref<HTMLElement | null>(null)
const inputValue = ref<number | undefined>(undefined)
Expand All @@ -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)
Expand Down Expand Up @@ -234,16 +246,60 @@ 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 (
<div
role="spinbutton"
tabindex={ props.disabled || props.readonly ? -1 : 0 }
aria-label={ t(`$vuetify.timePicker.${props.viewMode}`) }
aria-valuemin={ props.min }
aria-valuemax={ props.max }
aria-valuenow={ displayedValue.value }
aria-disabled={ props.disabled || undefined }
aria-readonly={ props.readonly || undefined }
class={[
{
'v-time-picker-clock': true,
'v-time-picker-clock--indeterminate': props.modelValue == null,
'v-time-picker-clock--readonly': props.readonly,
},
]}
onKeydown={ onKeydown }
onMousedown={ onMouseDown }
onTouchstart={ onMouseDown }
onWheel={ wheel }
Expand All @@ -269,24 +325,27 @@ export const VTimePickerClock = genericComponent()({
{
genChildren.value.map(value => {
const isActive = value === displayedValue.value
const isDisabled = props.disabled || !isAllowed(value)

return (
<div
<VBtn
Comment thread
ikushum marked this conversation as resolved.
_as="VTimePickerClockBtn"
aria-hidden="true"
tabindex={ -1 }
class={[
'v-time-picker-clock__item',
{
'v-time-picker-clock__item': true,
'v-time-picker-clock__item--active': isActive,
'v-time-picker-clock__item--disabled': props.disabled || !isAllowed(value),
},
isActive && backgroundColorClasses.value,
]}
style={[
getTransform(value),
isActive && backgroundColorStyles.value,
]}
color={ isActive ? props.color : '' }
disabled={ isDisabled }
style={[getTransform(value)]}
variant={ isActive ? 'flat' : 'text' }
ripple={ false }
>
<span>{ props.format(value) }</span>
</div>
{ props.format(value) }
</VBtn>
)
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => (
<VTimePicker
modelValue="10:30:45"
viewMode={ viewMode }
useSeconds={ useSeconds }
onUpdate:viewMode={ onUpdateViewMode }
/>
))

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(() => (
<VTimePicker
modelValue="10:30:45"
viewMode={ viewMode }
useSeconds={ useSeconds }
onUpdate:viewMode={ onUpdateViewMode }
/>
))

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<string | null>(null)
Expand Down
Loading
Loading