Skip to content

Commit

Permalink
fix: use focus composable to conditionally apply keyboard navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
Rohan Bansal committed May 6, 2024
1 parent 09cce10 commit 93316f4
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 56 deletions.
3 changes: 2 additions & 1 deletion aform/src/components/form/ADatePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const selectedDate = ref(new Date(date.value))
const currentMonth = ref<number>(selectedDate.value.getMonth())
const currentYear = ref<number>(selectedDate.value.getFullYear())
const currentDates = ref<number[]>([])
const adatepicker = ref<HTMLElement | null>(null)
onMounted(async () => {
populateMonth()
Expand Down Expand Up @@ -133,7 +134,7 @@ const monthAndYear = computed(() => {
// setup keyboard navigation
useKeyboardNav([
{
parent: 'table.adate',
parent: adatepicker,
selectors: 'td',
handlers: {
...defaultKeypressHandlers,
Expand Down
4 changes: 2 additions & 2 deletions atable/src/components/ACell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@
<script setup lang="ts">
import { computed, CSSProperties, inject, ref } from 'vue'
import { defaultKeypressHandlers, useKeyboardNav } from '@stonecrop/utilities'
import { KeypressHandlers, defaultKeypressHandlers, useKeyboardNav } from '@stonecrop/utilities'
import TableDataStore from '.'
const props = withDefaults(
defineProps<{
colIndex: number
rowIndex: number
tableid: string
addNavigation?: boolean | object
addNavigation?: boolean | KeypressHandlers
tabIndex?: number
clickHandler?: (event: MouseEvent) => void
}>(),
Expand Down
25 changes: 14 additions & 11 deletions atable/src/components/AExpansionRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { TableRow } from 'types'
import { inject, ref } from 'vue'
import { useKeyboardNav } from '@stonecrop/utilities'
import { type KeypressHandlers, useKeyboardNav } from '@stonecrop/utilities'
import TableDataStore from '.'
Expand All @@ -26,9 +26,7 @@ const props = withDefaults(
rowIndex: number
tableid: string
tabIndex?: number
addNavigation?: {
[key: string]: (ev: KeyboardEvent) => any
}
addNavigation?: boolean | KeypressHandlers
}>(),
{
tabIndex: -1,
Expand All @@ -43,18 +41,23 @@ const getRowExpandSymbol = () => {
return tableData.display[props.rowIndex].expanded ? '' : ''
}
if (props.addNavigation !== undefined) {
const keyboardNav = Object.assign({}, props.addNavigation)
keyboardNav['keydown.control.g'] = (event: KeyboardEvent) => {
event.stopPropagation()
event.preventDefault()
tableData.toggleRowExpand(props.rowIndex)
if (props.addNavigation) {
const handlers: KeypressHandlers = {
'keydown.control.g': (event: KeyboardEvent) => {
event.stopPropagation()
event.preventDefault()
tableData.toggleRowExpand(props.rowIndex)
},
}
if (typeof props.addNavigation === 'object') {
Object.assign(handlers, props.addNavigation)
}
useKeyboardNav([
{
selectors: rowEl,
handlers: keyboardNav,
handlers: handlers,
},
])
}
Expand Down
16 changes: 13 additions & 3 deletions atable/src/components/ARow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<script setup lang="ts">
import { TableRow } from 'types'
import { inject, ref } from 'vue'
import { useKeyboardNav } from '@stonecrop/utilities'
import { type KeypressHandlers, useKeyboardNav, defaultKeypressHandlers } from '@stonecrop/utilities'
import TableDataStore from '.'
Expand All @@ -31,10 +31,11 @@ const props = withDefaults(
rowIndex: number
tableid: string
tabIndex?: number
addNavigation?: object
addNavigation?: boolean | KeypressHandlers
}>(),
{
tabIndex: -1,
addNavigation: false, // default to allowing cell navigation
}
)
Expand Down Expand Up @@ -79,10 +80,19 @@ const toggleRowExpand = (rowIndex: number) => {
}
if (props.addNavigation) {
let handlers = defaultKeypressHandlers
if (typeof props.addNavigation === 'object') {
handlers = {
...handlers,
...props.addNavigation,
}
}
useKeyboardNav([
{
selectors: rowEl,
handlers: props.addNavigation,
handlers: handlers,
},
])
}
Expand Down
102 changes: 66 additions & 36 deletions utilities/src/composables/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { onMounted, onBeforeUnmount } from 'vue'
import { useElementVisibility } from '@/composables/visibility'
import { type WatchStopHandle, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useFocusWithin } from '@vueuse/core'

import type { KeyboardNavigationOptions, KeypressHandlers } from 'types'
import { useElementVisibility } from '@/composables/visibility'

// helper functions
const isVisible = (element: HTMLElement) => {
Expand Down Expand Up @@ -328,50 +329,67 @@ export const defaultKeypressHandlers: KeypressHandlers = {
}

export function useKeyboardNav(options: KeyboardNavigationOptions[]) {
const getSelectors = (option: KeyboardNavigationOptions) => {
// get parent element
let $parent: Element | null = null
const getParentElement = (option: KeyboardNavigationOptions) => {
let $parent: HTMLElement | null = null
if (option.parent) {
if (typeof option.parent === 'string') {
$parent = document.querySelector(option.parent)
} else if (option.parent instanceof Element) {
} else if (option.parent instanceof HTMLElement) {
$parent = option.parent
} else {
$parent = option.parent.value
}
}
return $parent
}

// generate a list of selector(s)
let selectors: Element[] = []

if (option.selectors) {
if (typeof option.selectors === 'string') {
selectors = $parent
? Array.from($parent.querySelectorAll(option.selectors))
: Array.from(document.querySelectorAll(option.selectors))
} else if (option.selectors instanceof Element) {
selectors.push(option.selectors)
} else {
if (Array.isArray(option.selectors.value)) {
for (const element of option.selectors.value) {
if (element instanceof Element) {
selectors.push(element)
} else {
selectors.push(element.$el as Element)
}
}
const getSelectorsFromOption = (option: KeyboardNavigationOptions) => {
// assumes that option.selectors is provided
const $parent = getParentElement(option)
let selectors: HTMLElement[] = []
if (typeof option.selectors === 'string') {
selectors = $parent
? Array.from($parent.querySelectorAll(option.selectors))
: Array.from(document.querySelectorAll(option.selectors))
} else if (Array.isArray(option.selectors)) {
for (const element of option.selectors) {
if (element instanceof HTMLElement) {
selectors.push(element)
} else {
selectors.push(option.selectors.value)
selectors.push(element.$el as HTMLElement)
}
}
} else if (option.selectors instanceof HTMLElement) {
selectors.push(option.selectors)
} else {
const $children = Array.from($parent.children)
selectors = $children.filter((selector: HTMLElement) => {
if (Array.isArray(option.selectors.value)) {
for (const element of option.selectors.value) {
if (element instanceof HTMLElement) {
selectors.push(element)
} else {
selectors.push(element.$el as HTMLElement)
}
}
} else {
selectors.push(option.selectors.value)
}
}
return selectors
}

const getSelectors = (option: KeyboardNavigationOptions) => {
const $parent = getParentElement(option)
let selectors: HTMLElement[] = []
if (option.selectors) {
selectors = getSelectorsFromOption(option)
} else if ($parent) {
// TODO: what should happen if no parent or selectors are provided?
const $children = Array.from($parent.children) as HTMLElement[]
selectors = $children.filter(selector => {
// ignore elements not in the tab order or are not visible
return isFocusable(selector) && isVisible(selector)
})
}

return selectors
}

Expand Down Expand Up @@ -420,21 +438,33 @@ export function useKeyboardNav(options: KeyboardNavigationOptions[]) {
}
}

const watchStopHandlers: WatchStopHandle[] = []
onMounted(() => {
for (const option of options) {
const $parent = getParentElement(option)
const selectors = getSelectors(option)
for (const selector of selectors) {
selector.addEventListener('keydown', getEventListener(option))
const listener = getEventListener(option)
const listenerElements = $parent
? [$parent] // watch for focus recursively within the parent element
: selectors // watch for focus on each selector element TODO: too much JS?

for (const element of listenerElements) {
const { focused } = useFocusWithin(ref(element))
const stopHandler = watch(focused, value => {
if (value) {
element.addEventListener('keydown', listener)
} else {
element.removeEventListener('keydown', listener)
}
})
watchStopHandlers.push(stopHandler)
}
}
})

onBeforeUnmount(() => {
for (const option of options) {
const selectors = getSelectors(option)
for (const selector of selectors) {
selector.removeEventListener('keydown', getEventListener(option))
}
for (const stopHandler of watchStopHandlers) {
stopHandler()
}
})
}
3 changes: 2 additions & 1 deletion utilities/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { App } from 'vue'

import { defaultKeypressHandlers, useKeyboardNav } from './composables/keyboard'
import type { KeypressHandlers } from '../types'

function install(app: App /* options */) {}

export { defaultKeypressHandlers, install, useKeyboardNav }
export { KeypressHandlers, defaultKeypressHandlers, install, useKeyboardNav }
11 changes: 9 additions & 2 deletions utilities/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ export type KeypressHandlers = {
}

export type KeyboardNavigationOptions = {
parent?: string | Element | Ref<Element>
selectors?: string | Element | Ref<Element> | Ref<Element[]> | Ref<ComponentPublicInstance[]>
parent?: string | HTMLElement | Ref<HTMLElement>
selectors?:
| string
| HTMLElement
| HTMLElement[]
| ComponentPublicInstance[]
| Ref<HTMLElement>
| Ref<HTMLElement[]>
| Ref<ComponentPublicInstance[]>
handlers?: KeypressHandlers
}

0 comments on commit 93316f4

Please sign in to comment.