[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: ensure keybindings always ignore modifiers and editable elements (#607)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Philippe Serhal
Daniel Roe
and committed by
GitHub
4571e288 a2b69221

+113 -49
+2 -2
app/app.vue
··· 42 42 function handleGlobalKeydown(e: KeyboardEvent) { 43 43 if (isEditableElement(e.target)) return 44 44 45 - if (e.key === '/') { 45 + if (isKeyWithoutModifiers(e, '/')) { 46 46 e.preventDefault() 47 47 48 48 // Try to find and focus search input on current page ··· 58 58 router.push('/search') 59 59 } 60 60 61 - if (e.key === '?') { 61 + if (isKeyWithoutModifiers(e, '?')) { 62 62 e.preventDefault() 63 63 showKbdHints.value = true 64 64 }
+6 -8
app/components/AppHeader.vue
··· 62 62 } 63 63 64 64 onKeyStroke( 65 - ',', 65 + e => isKeyWithoutModifiers(e, ',') && !isEditableElement(e.target), 66 66 e => { 67 - if (isEditableElement(e.target)) return 68 - 69 67 e.preventDefault() 70 68 navigateTo('/settings') 71 69 }, ··· 73 71 ) 74 72 75 73 onKeyStroke( 76 - 'c', 77 - e => { 74 + e => 75 + isKeyWithoutModifiers(e, 'c') && 76 + !isEditableElement(e.target) && 78 77 // Allow more specific handlers to take precedence 79 - if (e.defaultPrevented) return 80 - if (isEditableElement(e.target)) return 81 - 78 + !e.defaultPrevented, 79 + e => { 82 80 e.preventDefault() 83 81 navigateTo('/compare') 84 82 },
+5 -4
app/components/MobileMenu.vue
··· 29 29 watch(() => route.fullPath, closeMenu) 30 30 31 31 // Close on escape 32 - onKeyStroke('Escape', () => { 33 - if (isOpen.value) { 32 + onKeyStroke( 33 + e => isKeyWithoutModifiers(e, 'Escape') && isOpen.value, 34 + e => { 34 35 isOpen.value = false 35 - } 36 - }) 36 + }, 37 + ) 37 38 38 39 // Prevent body scroll when menu is open 39 40 const isLocked = useScrollLock(document)
+19 -22
app/pages/[...package].vue
··· 313 313 }) 314 314 315 315 onKeyStroke( 316 - '.', 316 + e => isKeyWithoutModifiers(e, '.') && !isEditableElement(e.target), 317 317 e => { 318 - if (isEditableElement(e.target)) return 319 - if (pkg.value && displayVersion.value) { 320 - e.preventDefault() 321 - navigateTo({ 322 - name: 'code', 323 - params: { 324 - path: [pkg.value.name, 'v', displayVersion.value.version], 325 - }, 326 - }) 327 - } 318 + if (pkg.value == null || displayVersion.value == null) return 319 + e.preventDefault() 320 + navigateTo({ 321 + name: 'code', 322 + params: { 323 + path: [pkg.value.name, 'v', displayVersion.value.version], 324 + }, 325 + }) 328 326 }, 329 327 { dedupe: true }, 330 328 ) 331 329 332 330 onKeyStroke( 333 - 'd', 331 + e => isKeyWithoutModifiers(e, 'd') && !isEditableElement(e.target), 334 332 e => { 335 - if (isEditableElement(e.target)) return 336 - if (docsLink.value) { 337 - e.preventDefault() 338 - navigateTo(docsLink.value) 339 - } 333 + if (!docsLink.value) return 334 + e.preventDefault() 335 + navigateTo(docsLink.value) 340 336 }, 341 337 { dedupe: true }, 342 338 ) 343 339 344 - onKeyStroke('c', e => { 345 - if (isEditableElement(e.target)) return 346 - if (pkg.value) { 340 + onKeyStroke( 341 + e => isKeyWithoutModifiers(e, 'c') && !isEditableElement(e.target), 342 + e => { 343 + if (!pkg.value) return 347 344 e.preventDefault() 348 345 router.push({ path: '/compare', query: { packages: pkg.value.name } }) 349 - } 350 - }) 346 + }, 347 + ) 351 348 352 349 defineOgImageComponent('Package', { 353 350 name: () => pkg.value?.name ?? 'Package',
+10 -11
app/pages/settings.vue
··· 5 5 const colorMode = useColorMode() 6 6 const { currentLocaleStatus, isSourceLocale } = useI18nStatus() 7 7 8 - // Escape to go back (but not when focused on form elements) 8 + // Escape to go back (but not when focused on form elements or modal is open) 9 9 onKeyStroke( 10 - 'Escape', 10 + e => 11 + isKeyWithoutModifiers(e, 'Escape') && 12 + !isEditableElement(e.target) && 13 + !document.documentElement.matches('html:has(:modal)'), 11 14 e => { 12 - const target = e.target as HTMLElement 13 - if ( 14 - !['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.tagName) && 15 - !document.documentElement.matches('html:has(:modal)') 16 - ) { 17 - e.preventDefault() 18 - router.back() 19 - } 15 + e.preventDefault() 16 + router.back() 20 17 }, 21 18 { dedupe: true }, 22 19 ) ··· 85 82 | 'system' 86 83 " 87 84 > 88 - <option value="system">{{ $t('settings.theme_system') }}</option> 85 + <option value="system"> 86 + {{ $t('settings.theme_system') }} 87 + </option> 89 88 <option value="light">{{ $t('settings.theme_light') }}</option> 90 89 <option value="dark">{{ $t('settings.theme_dark') }}</option> 91 90 </select>
+13
app/utils/input.ts
··· 13 13 if (!target || !(target instanceof HTMLElement)) return false 14 14 return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable 15 15 } 16 + 17 + /** 18 + * Check if a keyboard event matches a specific key without any modifier keys. 19 + */ 20 + export function isKeyWithoutModifiers(event: KeyboardEvent, key: string): boolean { 21 + return ( 22 + event.key.toLowerCase() === key.toLowerCase() && 23 + !event.altKey && 24 + !event.ctrlKey && 25 + !event.metaKey && 26 + !event.shiftKey 27 + ) 28 + }
+58 -2
test/e2e/interactions.spec.ts
··· 48 48 // Wait for navigation to /search (debounce is 250ms) 49 49 await expect(page).toHaveURL(/\/search/, { timeout: 10000 }) 50 50 51 - await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 }) 51 + await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ 52 + timeout: 15000, 53 + }) 52 54 53 55 // Home search input should be gone (we're on /search now) 54 56 await expect(homeSearchInput).not.toBeVisible() ··· 70 72 71 73 await expect(page).toHaveURL(/\/search/, { timeout: 10000 }) 72 74 73 - await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 }) 75 + await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ 76 + timeout: 15000, 77 + }) 74 78 75 79 const headerSearchInput = page.locator('#header-search') 76 80 await expect(headerSearchInput).toBeFocused() ··· 86 90 await expect(page).toHaveURL(/\/compare/) 87 91 }) 88 92 93 + test('"c" does not navigate when any modifier key is pressed', async ({ page, goto }) => { 94 + await goto('/settings', { waitUntil: 'hydration' }) 95 + 96 + await page.keyboard.press('Shift+c') 97 + await expect(page).toHaveURL(/\/settings/) 98 + await page.keyboard.press('Control+c') 99 + await expect(page).toHaveURL(/\/settings/) 100 + await page.keyboard.press('Alt+c') 101 + await expect(page).toHaveURL(/\/settings/) 102 + await page.keyboard.press('Meta+c') 103 + await expect(page).toHaveURL(/\/settings/) 104 + await page.keyboard.press('ControlOrMeta+Shift+c') 105 + await expect(page).toHaveURL(/\/settings/) 106 + }) 107 + 89 108 test('"c" on package page navigates to /compare with package pre-filled', async ({ 90 109 page, 91 110 goto, ··· 113 132 await expect(searchInput).toHaveValue('c') 114 133 }) 115 134 135 + test('"c" on package page does not navigate when any modifier key is pressed', async ({ 136 + page, 137 + goto, 138 + }) => { 139 + await goto('/vue', { waitUntil: 'hydration' }) 140 + 141 + await page.keyboard.press('Shift+c') 142 + await expect(page).toHaveURL(/\/vue/) 143 + await page.keyboard.press('Control+c') 144 + await expect(page).toHaveURL(/\/vue/) 145 + await page.keyboard.press('Alt+c') 146 + await expect(page).toHaveURL(/\/vue/) 147 + await page.keyboard.press('Meta+c') 148 + await expect(page).toHaveURL(/\/vue/) 149 + await page.keyboard.press('ControlOrMeta+Shift+c') 150 + await expect(page).toHaveURL(/\/vue/) 151 + }) 152 + 116 153 test('"," navigates to /settings', async ({ page, goto }) => { 117 154 await goto('/compare', { waitUntil: 'hydration' }) 118 155 119 156 await page.keyboard.press(',') 120 157 158 + await expect(page).toHaveURL(/\/settings/) 159 + }) 160 + 161 + test('"," does not navigate when any modifier key is pressed', async ({ page, goto }) => { 162 + await goto('/settings', { waitUntil: 'hydration' }) 163 + 164 + const searchInput = page.locator('#header-search') 165 + await searchInput.focus() 166 + await expect(searchInput).toBeFocused() 167 + 168 + await page.keyboard.press('Shift+,') 169 + await expect(page).toHaveURL(/\/settings/) 170 + await page.keyboard.press('Control+,') 171 + await expect(page).toHaveURL(/\/settings/) 172 + await page.keyboard.press('Alt+,') 173 + await expect(page).toHaveURL(/\/settings/) 174 + await page.keyboard.press('Meta+,') 175 + await expect(page).toHaveURL(/\/settings/) 176 + await page.keyboard.press('ControlOrMeta+Shift+,') 121 177 await expect(page).toHaveURL(/\/settings/) 122 178 }) 123 179