diff --git a/public/locales/en/files.json b/public/locales/en/files.json index fed7e94c3..dd96da470 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -66,6 +66,7 @@ "copy": "Copy selected item(s)", "paste": "Paste item(s)", "cut": "Cut selected item(s)", + "toggleSearch": "Toggle search filter", "showShortcuts": "Show keyboard shortcuts" }, "pinningModal": { diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index 6516588ca..3051a3bb2 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -53,10 +53,11 @@ const FilesPage = ({ setViewMode(newMode) } - const toggleSearch = () => setShowSearch(prev => { + // Toggle search filter visibility. When hiding, clear the filter text. + const toggleSearch = useCallback(() => setShowSearch(prev => { if (prev) filterRef.current = '' return !prev - }) + }), []) useEffect(() => { doFetchPinningServices() @@ -81,11 +82,25 @@ const FilesPage = ({ e.preventDefault() showModal(SHORTCUTS) } + + if (e.key === '/') { + e.preventDefault() + if (showSearch) { + // Search is visible but input lost focus, re-focus it. + document.getElementById('search-filter-input')?.focus() + } else { + // Open search and focus the input after React renders it. + toggleSearch() + requestAnimationFrame(() => { + document.getElementById('search-filter-input')?.focus() + }) + } + } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, []) + }, [toggleSearch, showSearch]) // Persist view mode changes to localStorage useEffect(() => { diff --git a/src/files/modals/shortcut-modal/shortcut-modal.js b/src/files/modals/shortcut-modal/shortcut-modal.js index d77b91372..c851601ec 100644 --- a/src/files/modals/shortcut-modal/shortcut-modal.js +++ b/src/files/modals/shortcut-modal/shortcut-modal.js @@ -121,6 +121,7 @@ const ShortcutModal = ({ onLeave, className, ...props }) => { ] const otherShortcuts = [ + { shortcut: '/', description: t('shortcutModal.toggleSearch') }, { shortcut: ['Shift', '?'], description: t('shortcutModal.showShortcuts') } ] diff --git a/src/files/search-filter/SearchFilter.tsx b/src/files/search-filter/SearchFilter.tsx index 365a84bd9..47f236d92 100644 --- a/src/files/search-filter/SearchFilter.tsx +++ b/src/files/search-filter/SearchFilter.tsx @@ -25,15 +25,25 @@ const SearchFilter = ({ initialValue = '', onFilterChange, filteredCount, totalC onFilterChange('') }, [onFilterChange]) + // Allow users to quickly clear the filter text by pressing Escape, + // so the full file listing is restored without needing to click the clear button. + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + clearFilter() + } + }, [clearFilter]) + return (
{filter && ( diff --git a/src/peers/PeersPage.js b/src/peers/PeersPage.js index 4f2bcc266..bf4c184d6 100644 --- a/src/peers/PeersPage.js +++ b/src/peers/PeersPage.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { connect } from 'redux-bundler-react' import { Helmet } from 'react-helmet' import { withTranslation } from 'react-i18next' @@ -15,7 +15,22 @@ import AddConnection from './AddConnection/AddConnection.js' import CliTutorMode from '../components/cli-tutor-mode/CliTutorMode.js' import { cliCmdKeys, cliCommandList } from '../bundles/files/consts.js' -const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => ( +const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => { + useEffect(() => { + const handleKeyDown = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return + } + if (e.key === '/') { + e.preventDefault() + document.getElementById('peers-filter-input')?.focus() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + + return (
{t('title')} | IPFS @@ -41,7 +56,8 @@ const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => ( locale={getJoyrideLocales(t)} showProgress />
-) + ) +} export default connect( 'selectToursEnabled', diff --git a/src/peers/PeersTable/PeersTable.js b/src/peers/PeersTable/PeersTable.js index e63bdd9a8..42494e9d1 100644 --- a/src/peers/PeersTable/PeersTable.js +++ b/src/peers/PeersTable/PeersTable.js @@ -117,14 +117,23 @@ const rowClassRenderer = ({ index }, peers = [], selectedPeers) => { return classNames('bb b--near-white peersTableItem', index === -1 && 'bg-near-white', shouldAddHoverEffect && 'bg-light-gray') } -const FilterInput = ({ setFilter, t, filteredCount }) => { +const FilterInput = ({ filter, setFilter, t, filteredCount }) => { + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + setFilter('') + } + } + return (
setFilter(e.target.value)} + onKeyDown={handleKeyDown} /> {/* Now to display the total number of peers filtered out on the right side of the inside of the input */}
{filteredCount}
@@ -187,7 +196,7 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers return (
- + { awaitedPeerLocationsForSwarm && {({ width }) => ( <> diff --git a/test/e2e/files.test.js b/test/e2e/files.test.js index 9786ee26d..0bbc5aac7 100644 --- a/test/e2e/files.test.js +++ b/test/e2e/files.test.js @@ -1,6 +1,6 @@ import { test, expect } from './setup/coverage.js' import { fixtureData } from './fixtures/index.js' -import { files, explore, dismissImportNotification } from './setup/locators.js' +import { files, explore, nav, dismissImportNotification } from './setup/locators.js' import { selectViewMode, toggleSearchFilter } from '../helpers/grid' import all from 'it-all' import filesize from 'filesize' @@ -404,6 +404,90 @@ test.describe('Files screen', () => { await expect(page.getByTestId('file-row').filter({ hasText: appleFile })).toBeVisible() }) + test('pressing Escape clears the filter', async ({ page }) => { + await selectViewMode(page, 'list') + await toggleSearchFilter(page, true) + + await files.searchInput(page).fill('orange') + + // only the orange file should be visible + await expect(page.getByTestId('file-row').filter({ hasText: orangeFile })).toBeVisible() + await expect(page.getByTestId('file-row').filter({ hasText: appleFile })).not.toBeVisible() + + // press Escape while focused on the search input + await files.searchInput(page).press('Escape') + + // filter should be cleared and both files visible again + await expect(files.searchInput(page)).toHaveValue('') + await expect(page.getByTestId('file-row').filter({ hasText: orangeFile })).toBeVisible() + await expect(page.getByTestId('file-row').filter({ hasText: appleFile })).toBeVisible() + }) + + test('pressing / opens search and auto-focuses input', async ({ page }) => { + await selectViewMode(page, 'list') + + // search should be hidden initially + await expect(files.searchInput(page)).not.toBeVisible() + + // press / to open search (focus must not be in an input) + await page.keyboard.press('/') + + // search input should appear and be focused + await expect(files.searchInput(page)).toBeVisible() + await expect(files.searchInput(page)).toBeFocused() + + // type a filter term directly (input is focused) + await page.keyboard.type('orange') + await expect(page.getByTestId('file-row').filter({ hasText: orangeFile })).toBeVisible() + await expect(page.getByTestId('file-row').filter({ hasText: appleFile })).not.toBeVisible() + }) + + test('pressing / re-focuses input when search is visible but not focused', async ({ page }) => { + await selectViewMode(page, 'list') + await toggleSearchFilter(page, true) + + await files.searchInput(page).fill('orange') + await expect(page.getByTestId('file-row').filter({ hasText: orangeFile })).toBeVisible() + + // move focus out of the search input + await files.searchInput(page).blur() + await expect(files.searchInput(page)).not.toBeFocused() + + // press / should re-focus the input, not hide search + await page.keyboard.press('/') + await expect(files.searchInput(page)).toBeVisible() + await expect(files.searchInput(page)).toBeFocused() + + // filter text should still be intact + await expect(files.searchInput(page)).toHaveValue('orange') + }) + + test('pressing / focuses search after navigating away and back', async ({ page }) => { + await selectViewMode(page, 'list') + await toggleSearchFilter(page, true) + + // confirm search input is visible + await expect(files.searchInput(page)).toBeVisible() + + // navigate to Status page + await nav.status(page).click() + await page.waitForURL('/#/') + + // navigate back to Files + await nav.files(page).click() + await page.waitForURL('/#/files') + + // search input should still be visible (persisted in localStorage) + await expect(files.searchInput(page)).toBeVisible() + + // but it should NOT be focused on page load + await expect(files.searchInput(page)).not.toBeFocused() + + // press / to focus the search input + await page.keyboard.press('/') + await expect(files.searchInput(page)).toBeFocused() + }) + test('no matches message', async ({ page }) => { await toggleSearchFilter(page, true) diff --git a/test/e2e/peers.test.js b/test/e2e/peers.test.js index 0916d9160..e5cc8ae3a 100644 --- a/test/e2e/peers.test.js +++ b/test/e2e/peers.test.js @@ -42,4 +42,25 @@ test.describe('Peers screen', () => { test('should have a peer from a "Local Network"', async ({ page }) => { await expect(peers.localNetwork(page).first()).toBeVisible() }) + + test('pressing / focuses the filter input', async ({ page }) => { + await expect(peers.filterInput(page)).toBeVisible() + await expect(peers.filterInput(page)).not.toBeFocused() + + await page.keyboard.press('/') + await expect(peers.filterInput(page)).toBeFocused() + }) + + test('pressing Escape clears the filter input', async ({ page }) => { + const filterInput = peers.filterInput(page) + await expect(filterInput).toBeVisible() + + // type a filter term + await filterInput.fill('nonexistent-peer-xyz') + await expect(filterInput).toHaveValue('nonexistent-peer-xyz') + + // press Escape to clear + await filterInput.press('Escape') + await expect(filterInput).toHaveValue('') + }) }) diff --git a/test/e2e/setup/locators.js b/test/e2e/setup/locators.js index a52b2a9ab..1ebdb8544 100644 --- a/test/e2e/setup/locators.js +++ b/test/e2e/setup/locators.js @@ -70,7 +70,8 @@ export const peers = { modal: (page) => page.getByTestId('ipfs-modal'), multiAddrInput: (page) => page.locator('input[name="maddr"]'), successIndicator: (page) => page.locator('.bg-green'), - localNetwork: (page) => page.getByText('Local Network') + localNetwork: (page) => page.getByText('Local Network'), + filterInput: (page) => page.locator('#peers-filter-input') } // Explore page locators