From a59b9ca1da987d6d228118a3cd49d3f2d3566659 Mon Sep 17 00:00:00 2001 From: Fendy Heryanto Date: Thu, 28 May 2026 13:10:59 +0000 Subject: [PATCH 1/3] fix(nc-gui): arrow-key navigation in link/unlink record modals --- .../virtual-cell/components/LinkedItems.vue | 49 ++++++++++++++----- .../virtual-cell/components/UnLinkedItems.vue | 49 ++++++++++++++----- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue b/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue index 7f1cf0e123..e5b22dbfc1 100644 --- a/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue +++ b/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue @@ -347,20 +347,42 @@ watch([filterQueryRef, isDataExist], () => { } }) -const linkedShortcuts = (e: KeyboardEvent) => { +function focusListItemByIndex(idx: number) { + const items = scrollContainerRef.value + ? Array.from(scrollContainerRef.value.querySelectorAll('[data-testid="nc-child-list-item"]')) + : [] + const wrapper = items[idx] + const focusable = wrapper?.querySelector('[tabindex="0"]') ?? wrapper + focusable?.focus() +} + +function linkedShortcuts(e: KeyboardEvent) { if (e.key === 'Escape') { vModel.value = false - } else if (e.key === 'ArrowDown') { + return + } + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const target = e.target as HTMLElement | null + const currentWrapper = target?.closest('[data-testid="nc-child-list-item"]') + if (!currentWrapper || !scrollContainerRef.value) return + 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 !== ' ') { + + const items = Array.from(scrollContainerRef.value.querySelectorAll('[data-testid="nc-child-list-item"]')) + const idx = items.indexOf(currentWrapper) + if (idx === -1) return + + if (e.key === 'ArrowDown') { + if (idx < items.length - 1) focusListItemByIndex(idx + 1) + } else if (e.key === 'ArrowUp') { + 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 (e) {} @@ -467,6 +489,11 @@ const handleKeyDown = (e: KeyboardEvent) => { if (childrenListPagination.query && ncIsArray(list) && list.length) { linkOrUnLink(list[0], '0') } + } else if (e.key === 'ArrowDown') { + e.preventDefault() + focusListItemByIndex(0) + } else if (e.key === 'ArrowUp') { + e.preventDefault() } } diff --git a/packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue b/packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue index 7fbf377892..ea98534e02 100644 --- a/packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue +++ b/packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue @@ -353,20 +353,42 @@ const onDeletedRecord = async () => { loadChildrenExcludedList(rowState.value, true) } -const linkedShortcuts = (e: KeyboardEvent) => { +function focusListItemByIndex(idx: number) { + const items = scrollContainerRef.value + ? Array.from(scrollContainerRef.value.querySelectorAll('[data-testid="nc-excluded-list-item"]')) + : [] + const wrapper = items[idx] + const focusable = wrapper?.querySelector('[tabindex="0"]') ?? wrapper + focusable?.focus() +} + +function linkedShortcuts(e: KeyboardEvent) { if (e.key === 'Escape') { vModel.value = false - } else if (e.key === 'ArrowDown') { + return + } + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const target = e.target as HTMLElement | null + const currentWrapper = target?.closest('[data-testid="nc-excluded-list-item"]') + if (!currentWrapper || !scrollContainerRef.value) return + 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 !== ' ') { + + const items = Array.from(scrollContainerRef.value.querySelectorAll('[data-testid="nc-excluded-list-item"]')) + const idx = items.indexOf(currentWrapper) + if (idx === -1) return + + if (e.key === 'ArrowDown') { + if (idx < items.length - 1) focusListItemByIndex(idx + 1) + } else if (e.key === 'ArrowUp') { + 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 (e) {} @@ -476,6 +498,11 @@ const handleKeyDown = (e: KeyboardEvent) => { ) { onClick(childrenExcludedList.value?.list[0], '0') } + } else if (e.key === 'ArrowDown') { + e.preventDefault() + focusListItemByIndex(0) + } else if (e.key === 'ArrowUp') { + e.preventDefault() } } From 72c492f3bcd10924e12ca978384cc67aedc9a40e Mon Sep 17 00:00:00 2001 From: Ramesh Mane <101566080+rameshmane7218@users.noreply.github.com> Date: Thu, 28 May 2026 13:10:59 +0000 Subject: [PATCH 2/3] refactor(nc-gui): extract LTAR list key-nav into shared composable --- .../virtual-cell/components/LinkedItems.vue | 78 ++++----------- .../virtual-cell/components/UnLinkedItems.vue | 82 ++++------------ .../nc-gui/composables/useLTARListKeyNav.ts | 95 +++++++++++++++++++ 3 files changed, 128 insertions(+), 127 deletions(-) create mode 100644 packages/nc-gui/composables/useLTARListKeyNav.ts diff --git a/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue b/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue index e5b22dbfc1..f04817090c 100644 --- a/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue +++ b/packages/nc-gui/components/virtual-cell/components/LinkedItems.vue @@ -48,6 +48,8 @@ const reloadViewDataTrigger = inject(ReloadViewDataHookInj, createEventHook()) const filterQueryRef = ref() +const scrollContainerRef = ref() + const { isDataReadOnly } = useRoles() const { isSharedBase } = storeToRefs(useBase()) @@ -347,51 +349,8 @@ watch([filterQueryRef, isDataExist], () => { } }) -function focusListItemByIndex(idx: number) { - const items = scrollContainerRef.value - ? Array.from(scrollContainerRef.value.querySelectorAll('[data-testid="nc-child-list-item"]')) - : [] - const wrapper = items[idx] - const focusable = wrapper?.querySelector('[tabindex="0"]') ?? wrapper - focusable?.focus() -} - -function linkedShortcuts(e: KeyboardEvent) { - if (e.key === 'Escape') { - vModel.value = false - return - } - - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - const target = e.target as HTMLElement | null - const currentWrapper = target?.closest('[data-testid="nc-child-list-item"]') - if (!currentWrapper || !scrollContainerRef.value) return - - e.preventDefault() - - const items = Array.from(scrollContainerRef.value.querySelectorAll('[data-testid="nc-child-list-item"]')) - const idx = items.indexOf(currentWrapper) - if (idx === -1) return - - if (e.key === 'ArrowDown') { - if (idx < items.length - 1) focusListItemByIndex(idx + 1) - } else if (e.key === 'ArrowUp') { - 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 (e) {} - } -} - onMounted(() => { loadRelatedTableMeta() - window.addEventListener('keydown', linkedShortcuts) // Load initial chunk for virtual scroll fetchChildrenChunk(0) @@ -403,8 +362,6 @@ onMounted(() => { }, 100) }) -const scrollContainerRef = ref() - const ROW_VIRTUAL_MARGIN = 5 const rowSlice = reactive({ start: 0, end: 0 }) @@ -467,7 +424,6 @@ onUnmounted(() => { resetChildrenListOffsetCount() resetChildrenCache() childrenListPagination.query = '' - window.removeEventListener('keydown', linkedShortcuts) }) const onFilterChange = () => { @@ -479,23 +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') - } - } else if (e.key === 'ArrowDown') { - e.preventDefault() - focusListItemByIndex(0) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - } -} + if (ncIsArray(list) && list.length) linkOrUnLink(list[0], '0') + }, +})