Merge pull request #13927 from nocodb/nc-fix/link-modal-arrow-keys

Nc fix(nc-gui): arrow-key navigation in link/unlink record modals
This commit is contained in:
Ramesh Mane
2026-05-29 01:26:05 +05:30
committed by GitHub
4 changed files with 132 additions and 73 deletions

View File

@@ -48,6 +48,8 @@ const reloadViewDataTrigger = inject(ReloadViewDataHookInj, createEventHook())
const filterQueryRef = ref<HTMLInputElement>()
const scrollContainerRef = ref<HTMLElement>()
const { isDataReadOnly } = useRoles()
const { isSharedBase } = storeToRefs(useBase())
@@ -347,29 +349,8 @@ watch([filterQueryRef, isDataExist], () => {
}
})
const linkedShortcuts = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
vModel.value = false
} else if (e.key === 'ArrowDown') {
e.preventDefault()
try {
e.target?.nextElementSibling?.focus()
} catch (e) {}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
try {
e.target?.previousElementSibling?.focus()
} catch (e) {}
} else if (!expandedFormDlg.value && e.key !== 'Tab' && e.key !== 'Shift' && e.key !== 'Enter' && e.key !== ' ') {
try {
filterQueryRef.value?.focus()
} catch (e) {}
}
}
onMounted(() => {
loadRelatedTableMeta()
window.addEventListener('keydown', linkedShortcuts)
// Load initial chunk for virtual scroll
fetchChildrenChunk(0)
@@ -381,8 +362,6 @@ onMounted(() => {
}, 100)
})
const scrollContainerRef = ref<HTMLElement>()
const ROW_VIRTUAL_MARGIN = 5
const rowSlice = reactive({ start: 0, end: 0 })
@@ -445,7 +424,6 @@ onUnmounted(() => {
resetChildrenListOffsetCount()
resetChildrenCache()
childrenListPagination.query = ''
window.removeEventListener('keydown', linkedShortcuts)
})
const onFilterChange = () => {
@@ -457,18 +435,21 @@ const onFilterChange = () => {
const isSearchInputFocused = ref(false)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (!childrenListPagination.query) emit('escape')
filterQueryRef.value?.blur()
} else if (e.key === 'Enter') {
const { handleSearchKeydown: handleKeyDown } = useLTARListKeyNav({
scrollContainerRef,
filterQueryRef,
itemTestId: 'nc-child-list-item',
expandedFormDlg,
closeModal: () => {
vModel.value = false
},
getQuery: () => childrenListPagination.query,
onEscapeEmptyQuery: () => emit('escape'),
onEnterWithQuery: () => {
const list = childrenList.value?.list ?? state.value?.[colTitle.value]
if (childrenListPagination.query && ncIsArray(list) && list.length) {
linkOrUnLink(list[0], '0')
}
}
}
if (ncIsArray(list) && list.length) linkOrUnLink(list[0], '0')
},
})
</script>
<template>

View File

@@ -32,6 +32,8 @@ const { isSharedBase } = storeToRefs(useBase())
const filterQueryRef = ref<HTMLInputElement>()
const scrollContainerRef = ref<HTMLElement>()
const { t } = useI18n()
const { $e } = useNuxtApp()
@@ -353,28 +355,6 @@ const onDeletedRecord = async () => {
loadChildrenExcludedList(rowState.value, true)
}
const linkedShortcuts = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
vModel.value = false
} else if (e.key === 'ArrowDown') {
e.preventDefault()
try {
e.target?.nextElementSibling?.focus()
} catch (e) {}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
try {
e.target?.previousElementSibling?.focus()
} catch (e) {}
} else if (!expandedFormDlg.value && e.key !== 'Tab' && e.key !== 'Shift' && e.key !== 'Enter' && e.key !== ' ') {
try {
filterQueryRef.value?.focus()
} catch (e) {}
}
}
const scrollContainerRef = ref<HTMLElement>()
const ROW_VIRTUAL_MARGIN = 5
const rowSlice = reactive({ start: 0, end: 0 })
@@ -435,7 +415,6 @@ const visibleRows = computed(() => {
})
onMounted(() => {
window.addEventListener('keydown', linkedShortcuts)
loadRelatedTableMeta()
// Load initial chunk
@@ -452,7 +431,6 @@ onUnmounted(() => {
resetChildrenExcludedOffsetCount()
resetExcludedCache()
childrenExcludedListPagination.query = ''
window.removeEventListener('keydown', linkedShortcuts)
})
const onFilterChange = () => {
@@ -464,20 +442,21 @@ const onFilterChange = () => {
const isSearchInputFocused = ref(false)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (!childrenExcludedListPagination.query) emit('escape')
filterQueryRef.value?.blur()
} else if (e.key === 'Enter') {
if (
childrenExcludedListPagination.query &&
ncIsArray(childrenExcludedList.value?.list) &&
childrenExcludedList.value?.list.length
) {
onClick(childrenExcludedList.value?.list[0], '0')
}
}
}
const { handleSearchKeydown: handleKeyDown } = useLTARListKeyNav({
scrollContainerRef,
filterQueryRef,
itemTestId: 'nc-excluded-list-item',
expandedFormDlg,
closeModal: () => {
vModel.value = false
},
getQuery: () => childrenExcludedListPagination.query,
onEscapeEmptyQuery: () => emit('escape'),
onEnterWithQuery: () => {
const list = childrenExcludedList.value?.list
if (ncIsArray(list) && list.length) onClick(list[0], '0')
},
})
</script>
<template>

View File

@@ -0,0 +1,95 @@
import type { Ref } from 'vue'
interface UseLTARListKeyNavOptions {
scrollContainerRef: Ref<HTMLElement | undefined>
filterQueryRef: Ref<HTMLInputElement | undefined>
itemTestId: string
expandedFormDlg: Ref<boolean>
closeModal: () => void
getQuery: () => string
onEscapeEmptyQuery: () => void
onEnterWithQuery: () => void
}
export function useLTARListKeyNav(options: UseLTARListKeyNavOptions) {
const {
scrollContainerRef,
filterQueryRef,
itemTestId,
expandedFormDlg,
closeModal,
getQuery,
onEscapeEmptyQuery,
onEnterWithQuery,
} = options
function getItems(): HTMLElement[] {
const container = scrollContainerRef.value
if (!container) return []
return Array.from(container.querySelectorAll<HTMLElement>(`[data-testid="${itemTestId}"]`))
}
function focusListItemByIndex(idx: number) {
const wrapper = getItems()[idx]
const focusable = wrapper?.querySelector<HTMLElement>('[tabindex="0"]') ?? wrapper
focusable?.focus()
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeModal()
return
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const target = e.target as HTMLElement | null
const currentWrapper = target?.closest<HTMLElement>(`[data-testid="${itemTestId}"]`)
if (!currentWrapper || !scrollContainerRef.value) return
e.preventDefault()
const items = getItems()
const idx = items.indexOf(currentWrapper)
if (idx === -1) return
if (e.key === 'ArrowDown') {
if (idx < items.length - 1) focusListItemByIndex(idx + 1)
} else if (idx === 0) {
filterQueryRef.value?.focus()
} else {
focusListItemByIndex(idx - 1)
}
return
}
if (!expandedFormDlg.value && e.key !== 'Tab' && e.key !== 'Shift' && e.key !== 'Enter' && e.key !== ' ') {
try {
filterQueryRef.value?.focus()
} catch {}
}
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (!getQuery()) onEscapeEmptyQuery()
filterQueryRef.value?.blur()
} else if (e.key === 'Enter') {
if (getQuery()) onEnterWithQuery()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
focusListItemByIndex(0)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', onWindowKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKeydown)
})
return { handleSearchKeydown }
}

View File

@@ -880,12 +880,14 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenExcludedListLinked.value[index] = true
isChildrenListLinked.value[index] = true
excludedLinkedState.value.set(index, true)
childrenCachedLinkedState.value.set(index, true)
return
}
try {
isChildrenExcludedListLoading.value[index] = true
isChildrenListLoading.value[index] = true
excludedLoadingState.value.set(index, true)
childrenCachedLoadingState.value.set(index, true)
childrenListOffsetCount.value = childrenListOffsetCount.value + 1
childrenExcludedOffsetCount.value = childrenExcludedOffsetCount.value + 1
@@ -904,6 +906,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenExcludedListLinked.value[index] = true
isChildrenListLinked.value[index] = true
excludedLinkedState.value.set(index, true)
childrenCachedLinkedState.value.set(index, true)
if (!isSingleTargetRelation.value) {
childrenListCount.value = childrenListCount.value + 1
@@ -929,6 +932,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenExcludedListLoading.value[index] = false
isChildrenListLoading.value[index] = false
excludedLoadingState.value.set(index, false)
childrenCachedLoadingState.value.set(index, false)
}
_reloadData?.({ shouldShowLoading: false, path: path.value })