Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): select, date-input, time-input keyboard navigation #3523

Merged
4 changes: 4 additions & 0 deletions packages/ui/src/components/va-date-input/VaDateInput.demo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@
<va-date-input ref="dateInputRef" v-model="value" :rules="validationRules1" clearable />
<va-button @click="focusDateInput">Focus</va-button>
<va-button @click="blurDateInput">Blur</va-button>

<va-button @click="($refs.otherElement as any).focus()">Focus other element</va-button>

<input value="Other element" ref="otherElement" />
</VbCard>
</VbDemo>
</template>
Expand Down
35 changes: 27 additions & 8 deletions packages/ui/src/components/va-date-input/VaDateInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
:style="$attrs.style"
v-bind="dropdownPropsComputed"
@open="focusDatePicker"
@close="focus"
>
<template #anchor>
<slot name="input" v-bind="{ valueText, inputAttributes: inputAttributesComputed, inputWrapperProps, inputListeners }">
Expand Down Expand Up @@ -68,7 +67,7 @@
</slot>
</template>

<va-dropdown-content class="va-date-input__dropdown-content">
<va-dropdown-content class="va-date-input__dropdown-content" @keydown.esc="focus()">
<va-date-picker
ref="datePicker"
v-bind="datePickerProps"
Expand Down Expand Up @@ -114,7 +113,7 @@ import {
useValidation, useValidationEmits, useValidationProps, ValidationProps,
useStateful, useStatefulEmits,
useParsable,
useFocus, useFocusEmits, useTranslation,
useFocus, useFocusEmits, useTranslation, useFocusDeep, useTrapFocus,
} from '../../composables'
import { useSyncProp } from '../va-date-picker/hooks/sync-prop'
import { useRangeModelValueGuard } from './hooks/range-model-value-guard'
Expand All @@ -129,11 +128,12 @@ import VaDatePicker from '../va-date-picker/VaDatePicker.vue'
import { VaDropdown, VaDropdownContent } from '../va-dropdown'
import { VaInputWrapper } from '../va-input'
import { VaIcon } from '../va-icon'
import { unwrapEl } from '../../utils/unwrapEl'

const VaInputWrapperProps = extractComponentProps(VaInputWrapper, ['focused', 'maxLength', 'counterValue'])
m0ksem marked this conversation as resolved.
Show resolved Hide resolved
const VaDatePickerProps = extractComponentProps(VaDatePicker)
const VaDropdownProps = extractComponentProps(VaDropdown,
['innerAnchorSelector', 'stateful', 'keyboardNavigation', 'modelValue'],
['innerAnchorSelector', 'stateful', 'keyboardNavigation', 'modelValue', 'trigger'],
)

export default defineComponent({
Expand Down Expand Up @@ -198,11 +198,24 @@ export default defineComponent({
const input = shallowRef<HTMLInputElement>()
const datePicker = ref<typeof VaDatePicker>()

const { trapFocusIn, freeFocus } = useTrapFocus()

watch(datePicker, (ref) => {
const el = unwrapEl(ref)
if (!el) {
freeFocus()
return
}

trapFocusIn(el)
})

const { isOpen, resetOnClose } = toRefs(props)
const { valueComputed: statefulValue }: { valueComputed: WritableComputedRef<DateInputModelValue> } = useStateful(props, emit)
const { syncProp: isOpenSync } = useSyncProp(isOpen, 'is-open', emit, false)

const { isFocused, focus, blur, onFocus: focusListener, onBlur: blurListener } = useFocus(input)
const { isFocused: isInputFocused, focus, blur, onFocus: focusListener, onBlur: blurListener } = useFocus(input)
const isPickerFocused = useFocusDeep(datePicker)

const isRangeModelValueGuardDisabled = computed(() => !resetOnClose.value)

Expand Down Expand Up @@ -341,7 +354,11 @@ export default defineComponent({
return { cursor: 'pointer' }
})

const iconTabindexComputed = computed(() => props.disabled || props.readonly ? -1 : 0)
const iconTabindexComputed = computed(() => {
if (!props.manualInput) { return -1 }

return props.disabled || props.readonly ? -1 : 0
})

const iconProps = computed(() => ({
role: 'button',
Expand All @@ -355,7 +372,7 @@ export default defineComponent({
const filteredWrapperProps = filterComponentProps(VaInputWrapperProps)
const computedInputWrapperProps = computed(() => ({
...filteredWrapperProps.value,
focused: isFocused.value,
focused: isInputFocused.value || isPickerFocused.value,
error: hasError.value,
errorMessages: computedErrorMessages.value,
readonly: props.readonly || !props.manualInput,
Expand Down Expand Up @@ -404,6 +421,7 @@ export default defineComponent({
closeOnAnchorClick: false,
keyboardNavigation: true,
innerAnchorSelector: '.va-input-wrapper__field',
trigger: 'none' as const,
}))

return {
Expand All @@ -415,7 +433,8 @@ export default defineComponent({
isOpenSync,
onInputTextChanged,

isFocused,
isInputFocused,
isPickerFocused,

input,
inputWrapperProps: computedInputWrapperProps,
Expand Down
13 changes: 11 additions & 2 deletions packages/ui/src/components/va-dropdown/VaDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useCursorAnchor } from './hooks/useCursorAnchor'
import { DropdownOffsetProp } from './types'
import { useDropdown } from './hooks/useDropdown'
import { warn } from '../../utils/console'
import { useFocusOutside } from '../../composables/useFocusOutside'

export default defineComponent({
name: 'VaDropdown',
Expand All @@ -49,6 +50,7 @@ export default defineComponent({
disabled: { type: Boolean },
readonly: { type: Boolean },
closeOnClickOutside: { type: Boolean, default: true },
closeOnFocusOutside: { type: Boolean, default: true },
closeOnAnchorClick: { type: Boolean, default: true },
closeOnContentClick: { type: Boolean, default: true },
hoverOverTimeout: { type: Number, default: 30 },
Expand All @@ -69,7 +71,7 @@ export default defineComponent({
ariaLabel: { type: String, default: '$t:toggleDropdown' },
},

emits: [...useStatefulEmits, 'anchor-click', 'anchor-right-click', 'content-click', 'click-outside', 'close', 'open', 'anchor-dblclick'],
emits: [...useStatefulEmits, 'anchor-click', 'anchor-right-click', 'content-click', 'click-outside', 'focus-outside', 'close', 'open', 'anchor-dblclick'],

setup (props, { emit }) {
const { valueComputed: statefulVal } = useStateful(props, emit)
Expand Down Expand Up @@ -173,7 +175,7 @@ export default defineComponent({

const emitAndClose = (eventName: Parameters<typeof emit>[0], close?: boolean, e?: Event) => {
emit(eventName, e)
if (close && props.trigger !== 'none') { valueComputed.value = false }
if (close) { valueComputed.value = false }
}

const floatingListeners = {
Expand All @@ -188,6 +190,12 @@ export default defineComponent({
}
})

useFocusOutside([floating], () => {
if (props.closeOnFocusOutside && valueComputed.value) {
emitAndClose('focus-outside', props.closeOnFocusOutside)
}
})

const anchorComputed = computed(() => {
return cursorAnchor.value || anchor.value
})
Expand Down Expand Up @@ -225,6 +233,7 @@ export default defineComponent({
show,
}
},

render () {
const floatingSlotNode = this.showFloating && renderSlotNode(this.$slots.default, {}, {
ref: 'floating',
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/components/va-select/VaSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
@focus="onInputFocus"
@blur="onInputBlur"
@click="focusAutocompleteInput"
@keydown.enter="toggleDropdown"
@keydown.space.stop.prevent="toggleDropdown"
>
<template
v-for="(_, name) in $slots"
Expand Down Expand Up @@ -71,6 +73,7 @@
<va-dropdown-content
class="va-select-dropdown__content"
:style="{ width: $props.width }"
@keydown.esc="hideAndFocus"
>
<va-input
v-if="showSearchInput"
Expand Down Expand Up @@ -666,9 +669,11 @@ export default defineComponent({
}
}

const toggleDropdown = () => {
const toggleDropdown = (e: KeyboardEvent) => {
if (props.disabled || props.readonly) { return }

if (e.code === 'Space' && props.autocomplete) { return }

showDropdownContentComputed.value = !showDropdownContentComputed.value
}

Expand Down
12 changes: 8 additions & 4 deletions packages/ui/src/components/va-time-input/VaTimeInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
class="va-time-input__anchor"
:style="cursorStyleComputed"
v-bind="computedInputWrapperProps"
@click.stop="toggleDropdown"
>
<template #default>
<input
Expand Down Expand Up @@ -73,7 +72,7 @@
<va-dropdown-content
no-padding
@keydown.esc.prevent="hideDropdown"
@keypress.enter.prevent="hideDropdown"
@keydown.enter.prevent="hideDropdown"
>
<va-time-picker
ref="timePicker"
Expand Down Expand Up @@ -108,7 +107,7 @@ import { VaDropdown, VaDropdownContent } from '../va-dropdown'

const VaInputWrapperProps = extractComponentProps(VaInputWrapper, ['focused', 'maxLength', 'counterValue'])
const VaDropdownProps = extractComponentProps(VaDropdown,
['keyboardNavigation', 'innerAnchorSelector', 'modelValue'],
['keyboardNavigation', 'innerAnchorSelector', 'modelValue', 'trigger'],
)

export default defineComponent({
Expand Down Expand Up @@ -309,7 +308,11 @@ export default defineComponent({
return { cursor: 'pointer' }
})

const iconTabindexComputed = computed(() => props.disabled || props.readonly ? -1 : 0)
const iconTabindexComputed = computed(() => {
if (!props.manualInput) { return -1 }

return props.disabled || props.readonly ? -1 : 0
})

const iconProps = computed(() => ({
role: 'button',
Expand Down Expand Up @@ -340,6 +343,7 @@ export default defineComponent({
...filteredProps.value,
keyboardNavigation: true,
innerAnchorSelector: '.va-input-wrapper__field',
trigger: 'none' as const,
}))

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useFocus } from '../useFocus'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { ref, nextTick } from 'vue'
import { describe, it, expect } from 'vitest'

// Disabled, because document.activeElement is not changing in jsdom
// See: https://github.com/jsdom/jsdom/issues/2586#issuecomment-627155416
describe('useFocus', () => {
it(
'focus & blur events should change isFocused value',
Expand All @@ -18,9 +20,9 @@ describe('useFocus', () => {

const wrapper = mount(TestComponent)
expect(wrapper.exists()).toBeTruthy()

await wrapper.trigger('focus')
await wrapper.vm.$nextTick(() => expect(wrapper.vm.isFocused).toBe(true))
await wrapper.vm.$nextTick(async () => nextTick(() => expect(wrapper.vm.isFocused).toBe(true)))

await wrapper.trigger('blur')
await wrapper.vm.$nextTick(() => expect(wrapper.vm.isFocused).toBe(false))
Expand Down
17 changes: 17 additions & 0 deletions packages/ui/src/composables/useActiveElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { onMounted, shallowRef } from 'vue'
import { useCaptureEvent } from './useCaptureEvent'

export const useActiveElement = () => {
const activeEl = shallowRef<HTMLElement>()

const updateActiveElement = () => {
activeEl.value = document.activeElement as HTMLElement
}

onMounted(updateActiveElement)

useCaptureEvent('focus', updateActiveElement)
useCaptureEvent('blur', updateActiveElement)

return activeEl
}
1 change: 1 addition & 0 deletions packages/ui/src/composables/useClearable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const useClearable = (
name: props.clearableIcon,
color: clearIconColor.value,
size: 'small',
tabindex: canBeCleared.value ? 0 : -1,
}))

return {
Expand Down
16 changes: 9 additions & 7 deletions packages/ui/src/composables/useEvent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Ref, unref, watch } from 'vue'
import { Component, Ref, unref, watch } from 'vue'
import { useWindow } from './useWindow'
import { unwrapEl } from '../utils/unwrapEl'
import { addEventListener, removeEventListener } from '../utils/add-event-listener'

type MaybeRef<T> = Ref<T> | T

Expand All @@ -21,19 +23,19 @@ type UseEventEvent<N extends UseEventEventName, D> = N extends keyof GlobalEvent
export const useEvent = <N extends UseEventEventName, E extends Event>(
event: N,
listener: (this: GlobalEventHandlers, event: UseEventEvent<N, E>) => any,
target?: MaybeRef<GlobalEventHandlers | undefined | null> | boolean,
target?: MaybeRef<unknown> | boolean,
) => {
const source = target && typeof target !== 'boolean' ? target : useWindow()
const source = (target && typeof target !== 'boolean') ? target : useWindow()
const capture = typeof target === 'boolean' ? target : false

watch(source, (newValue, oldValue) => {
if (!Array.isArray(event)) {
unref(newValue)?.addEventListener(event, listener as any, capture)
unref(oldValue)?.removeEventListener(event, listener as any, capture)
addEventListener(unwrapEl(unref(newValue)), event, listener as any, capture)
removeEventListener(unwrapEl(unref(oldValue)), event, listener as any, capture)
} else {
event.forEach((e) => {
unref(newValue)?.addEventListener(e, listener as any, capture)
unref(oldValue)?.removeEventListener(e, listener as any, capture)
addEventListener(unwrapEl(unref(newValue)), e, listener as any, capture)
removeEventListener(unwrapEl(unref(oldValue)), e, listener as any, capture)
})
}
}, { immediate: true })
Expand Down
38 changes: 19 additions & 19 deletions packages/ui/src/composables/useFocus.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
import { unwrapEl } from './../utils/unwrapEl'
import { focusElement, blurElement } from './../utils/focus'
import { ref, onMounted, onBeforeUnmount, Ref } from 'vue'
import { ref, onMounted, onBeforeUnmount, Ref, Component, computed } from 'vue'
import { useEvent } from './useEvent'
import { useActiveElement } from './useActiveElement'

export const useFocusEmits = ['focus', 'blur'] as const

export function useFocus (
el?: Ref<HTMLElement | null | undefined>,
el?: Ref<HTMLElement | null | undefined | Component>,
emit?: (event: 'focus' | 'blur', e?: Event) => void,
) {
const isFocused = ref(false)
const activeElement = useActiveElement()
const isFocused = computed({
get: () => {
return activeElement.value === el?.value
},
set: (value) => {
if (value) {
focus()
} else {
blur()
}
},
})

const onFocus = (e?: Event) => {
isFocused.value = true
emit?.('focus', e)
}

const onBlur = (e?: Event) => {
isFocused.value = false
emit?.('blur', e)
}

Expand All @@ -30,20 +42,8 @@ export function useFocus (
blurElement(unwrapEl(el?.value))
}

let element: any
onMounted(() => {
element = (el?.value as any)?.$el ?? el?.value
if (element) {
element.addEventListener('focus', onFocus)
element.addEventListener('blur', onBlur)
}
})
onBeforeUnmount(() => {
if (element) {
element.removeEventListener('focus', onFocus)
element.removeEventListener('blur', onBlur)
}
})
useEvent('focus', onFocus, el)
useEvent('blur', onBlur, el)

return {
isFocused,
Expand Down
Loading