From be8ef7bbbed04bddac3ddd6f0882955bf25b013b Mon Sep 17 00:00:00 2001 From: Johan Bisse Mattsson Date: Mon, 20 Jan 2025 13:02:55 +0100 Subject: [PATCH] feat(supersearch): Add persistent item support (LWS-289) (#1205) * Add leading persistent row support * Rename persistent item prop * Fix tests * Use default testid naming convention * Rename id and class name of suggestion items * Update readme --- packages/supersearch/README.md | 3 +- packages/supersearch/e2e/supersearch.spec.ts | 179 +++++++----------- .../src/lib/components/SuperSearch.svelte | 39 ++-- packages/supersearch/src/routes/+page.svelte | 35 +++- 4 files changed, 126 insertions(+), 130 deletions(-) diff --git a/packages/supersearch/README.md b/packages/supersearch/README.md index 51d292a5b..74d79491d 100644 --- a/packages/supersearch/README.md +++ b/packages/supersearch/README.md @@ -31,6 +31,7 @@ To use `supersearch` in a non-Svelte project ... | `extensions` | `Extension[]` | A list of extensions which should extend the default extensions. | `[]` | | `comboboxAriaLabel` | `string` | A string defining an optional aria label for the combobox | `undefined` | | `resultItem` | `Snippet<[ResultItem, getCellId, isFocusedCell, rowIndex]>` | A [Snippet](https://svelte.dev/docs/svelte/snippet) used for customized rendering of result items. See [Custom result items](#result-items). | `undefined` | +| `persistentItem` | `Snippet<[getCellId, isFocusedCell]>` | An optional Snippet used for adding persitent items (placed before result items). | `undefined` | | `submitAction` | `Snippet<[onclick]>` | An optional Snippet for adding a custom submit button | `undefined` | | `clearAction` | `Snippet<[onclick]>` | An optional Snippet for adding a clear button (used for clearing the input) | `undefined` | | `closeAction` | `Snippet<[onclick]>` | An optional Snippet for adding a close button (used for closing the expanded search) | `undefined` | @@ -57,7 +58,7 @@ The follwing snippet params are available (in order): 1. `ResultItem`- An individual item of the resulting data from `queryFn` and `transformFn`. The data inside can be of arbitary shape so they can be rendered in any shape you want. -2. `getCellId<[cellIndex: number]>` - A helper function to get a calculated ID for the cell (e.g. `#supersearch-result-item-0x0`) by passing a cell/column index value. This enables assistive technologies to know which element the application regards as focused while DOM focus remains on the input element. +2. `getCellId<[cellIndex: number]>` - A helper function to get a calculated ID for the cell (e.g. `#supersearch-item-0x0`) by passing a cell/column index value. This enables assistive technologies to know which element the application regards as focused while DOM focus remains on the input element. 3. `isFocusedCell[cellIndex: number]` - A helper function which returns a boolean value if the cell is focused (useful for styling). diff --git a/packages/supersearch/e2e/supersearch.spec.ts b/packages/supersearch/e2e/supersearch.spec.ts index a69c93304..877274e9d 100644 --- a/packages/supersearch/e2e/supersearch.spec.ts +++ b/packages/supersearch/e2e/supersearch.spec.ts @@ -9,105 +9,89 @@ test('prevents new line characters (e.g. when pasting multi-lined text)', async context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click(); + await page.getByTestId('test1').getByRole('combobox').first().click(); await page.evaluate(() => navigator.clipboard.writeText(`One two three`) ); await page.keyboard.press(`ControlOrMeta+v`); - await expect(page.locator('[data-test-id="test1"]').getByRole('combobox').first()).toHaveText( - 'One two three' - ); + await expect(page.getByTestId('test1').getByRole('combobox').first()).toHaveText('One two three'); }); test('expanded search is closable', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').click(); - await expect(page.locator('[data-test-id="test1"]').getByRole('dialog').first()).toBeVisible(); + await page.getByTestId('test1').getByRole('combobox').click(); + await expect(page.getByTestId('test1').getByRole('dialog').first()).toBeVisible(); await page.keyboard.press('Escape'); await expect( - page.locator('[data-test-id="test1"]').getByRole('dialog').first(), + page.getByTestId('test1').getByRole('dialog').first(), 'by pressing the Escape key' ).not.toBeVisible(); - await page.locator('[data-test-id="test1"]').getByRole('combobox').click(); + await page.getByTestId('test1').getByRole('combobox').click(); await page.mouse.click(0, 0); await expect( - page.locator('[data-test-id="test1"]').getByRole('dialog').first(), + page.getByTestId('test1').getByRole('dialog').first(), 'by clicking outside' ).not.toBeVisible(); await page.setViewportSize(devices['iPhone X'].viewport); - await page.locator('[data-test-id="test1"]').getByRole('combobox').click(); + await page.getByTestId('test1').getByRole('combobox').click(); await page.locator('[aria-label="Close"]').click(); await expect( - page.locator('[data-test-id="test1"]').getByRole('dialog').first(), + page.getByTestId('test1').getByRole('dialog').first(), 'by pressing close action' ).not.toBeVisible(); }); test('expanded search is togglable using keyboard shortcut', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().press('Tab'); + await page.getByTestId('test1').getByRole('combobox').first().press('Tab'); await page.keyboard.press('ControlOrMeta+k'); - await expect(page.locator('[data-test-id="test1"]').getByRole('dialog').first()).toBeVisible(); + await expect(page.getByTestId('test1').getByRole('dialog').first()).toBeVisible(); await page.keyboard.press('ControlOrMeta+k'); - await expect( - page.locator('[data-test-id="test1"]').getByRole('dialog').first() - ).not.toBeVisible(); + await expect(page.getByTestId('test1').getByRole('dialog').first()).not.toBeVisible(); }); test('supports keyboard navigation between rows and columns/cells', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().fill('a'); - const comboboxElement = page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox'); + await page.getByTestId('test1').getByRole('combobox').first().fill('a'); + const comboboxElement = page.getByTestId('test1').getByRole('dialog').getByRole('combobox'); await expect( comboboxElement, 'first row and cell is selected by default (if defaultRow is set to 0)' - ).toHaveAttribute('aria-activedescendant', 'supersearch-result-item-0x0'); - await expect(page.locator('#supersearch-result-item-0x0')).toHaveClass(/focused-cell/); + ).toHaveAttribute('aria-activedescendant', 'supersearch-item-0x0'); + await expect(page.locator('#supersearch-item-0x0')).toHaveClass(/focused-cell/); await page.keyboard.press('ArrowDown'); - await expect(comboboxElement).toHaveAttribute( - 'aria-activedescendant', - 'supersearch-result-item-1x0' - ); - await expect(page.locator('#supersearch-result-item-1x0')).toHaveClass(/focused-cell/); + await page.keyboard.press('ArrowDown'); + await expect(comboboxElement).toHaveAttribute('aria-activedescendant', 'supersearch-item-2x0'); + await expect(page.locator('#supersearch-item-2x0')).toHaveClass(/focused-cell/); await page.keyboard.press('ArrowRight'); - await expect(comboboxElement).toHaveAttribute( - 'aria-activedescendant', - 'supersearch-result-item-1x1' - ); + await expect(comboboxElement).toHaveAttribute('aria-activedescendant', 'supersearch-item-2x1'); await page.keyboard.press('ArrowLeft'); - await expect(comboboxElement).toHaveAttribute( - 'aria-activedescendant', - 'supersearch-result-item-1x0' - ); + await expect(comboboxElement).toHaveAttribute('aria-activedescendant', 'supersearch-item-2x0'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); - await expect(comboboxElement).toHaveAttribute( - 'aria-activedescendant', - 'supersearch-result-item-1x2' - ); + await expect(comboboxElement).toHaveAttribute('aria-activedescendant', 'supersearch-item-2x2'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await expect( comboboxElement, `selects closest cell if latest column isn't available on new row` - ).toHaveAttribute('aria-activedescendant', 'supersearch-result-item-3x1'); - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().fill('ab'); + ).toHaveAttribute('aria-activedescendant', 'supersearch-item-4x1'); + await page.getByTestId('test1').getByRole('combobox').first().fill('ab'); await expect( comboboxElement, 'focused cell is reset if user updates value in combobox' - ).toHaveAttribute('aria-activedescendant', 'supersearch-result-item-0x0'); - await expect(page.locator('#supersearch-result-item-0x0')).toHaveClass(/focused-cell/); + ).toHaveAttribute('aria-activedescendant', 'supersearch-item-0x0'); + await expect(page.locator('#supersearch-item-0x0')).toHaveClass(/focused-cell/); + await page.keyboard.press('Tab'); + await expect(page.locator('#supersearch-item-1x0')).toHaveClass(/focused-cell/); await page.keyboard.press('Tab'); - await expect(page.locator('#supersearch-result-item-1x0')).toHaveClass(/focused-cell/); await page.keyboard.press('Tab'); - await expect(page.locator('#supersearch-result-item-1x1')).toHaveClass(/focused-cell/); + await expect(page.locator('#supersearch-item-2x1')).toHaveClass(/focused-cell/); + await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Shift+Tab'); await expect( - page.locator('#supersearch-result-item-0x0'), + page.locator('#supersearch-item-0x0'), 'ensure focus is kept inside result items when shift-tabbing on first row' ).toHaveClass(/focused-cell/); await page.keyboard.press('ArrowDown'); @@ -119,41 +103,26 @@ test('supports keyboard navigation between rows and columns/cells', async ({ pag await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await expect( - page.locator('#supersearch-result-item-9x2'), + page.locator('#supersearch-item-10x2'), 'ensure focus is kept inside result items when tabbing on last row' ).toHaveClass(/focused-cell/); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox') - .press('Escape'); - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click(); - await expect(page.locator('#supersearch-result-item-0x0')).toHaveClass(/focused-cell/); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').press('Escape'); + await page.getByTestId('test1').getByRole('combobox').first().click(); + await expect(page.locator('#supersearch-item-0x0')).toHaveClass(/focused-cell/); }); test('syncs collapsed and expanded editor views', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click(); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox') - .fill('Hello world'); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox') - .selectText(); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox') - .press('Escape'); + await page.getByTestId('test1').getByRole('combobox').first().click(); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').fill('Hello world'); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').selectText(); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').press('Escape'); await expect( - await page.locator('[data-test-id="test1"]').getByRole('combobox').first(), + await page.getByTestId('test1').getByRole('combobox').first(), 'contents should be synced' ).toHaveText('Hello world'); expect( @@ -163,29 +132,25 @@ test('syncs collapsed and expanded editor views', async ({ page }) => { }); test('fires click events on focused cells', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().fill('a'); - await expect(page.locator('#supersearch-result-item-0x0')).toHaveClass(/focused-cell/); + await page.getByTestId('test1').getByRole('combobox').first().fill('a'); + await expect(page.locator('#supersearch-item-1x0')).toBeVisible(); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); - await expect(page).toHaveURL('/test1#supersearch-result-item-1x0'); + await expect(page).toHaveURL('/test1#supersearch-item-1x0'); }); test('fetches and displays paginated results', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click(); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox') - .fill('Hello'); - await expect(page.locator('[data-test-id="result-item"]').first()).toContainText('Heading 1'); - await expect(page.locator('[data-test-id="result-item"]')).toHaveCount(10); + await page.getByTestId('test1').getByRole('combobox').first().click(); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').fill('Hello'); + await expect(page.getByTestId('result-item').first()).toContainText('Heading 1'); + await expect(page.getByTestId('result-item')).toHaveCount(10); await page.locator('.supersearch-show-more').click(); // show more button will probably be removed in favour of automatic fetching when the user scrolls to the end - await expect(page.locator('[data-test-id="result-item"]')).toHaveCount(20); + await expect(page.getByTestId('result-item')).toHaveCount(20); await page.locator('.supersearch-show-more').click(); - await expect(page.locator('[data-test-id="result-item"]')).toHaveCount(30); + await expect(page.getByTestId('result-item')).toHaveCount(30); await expect(page.locator('.supersearch-show-more')).not.toBeAttached(); await expect( - page.locator('[data-test-id="result-item"]').first(), + page.getByTestId('result-item').first(), 'to tranform data using transformFn if available' ).toHaveText('Heading 1 for "Hello"'); }); @@ -193,39 +158,35 @@ test('fetches and displays paginated results', async ({ page }) => { test('submits form identified by form attribute on enter key press (if no result item is selected)', async ({ page }) => { - await page.locator('[data-test-id="test2"]').getByRole('combobox').first().fill('hello world'); + await page.getByTestId('test2').getByRole('combobox').first().fill('hello world'); await page.keyboard.press('Enter'); await expect(page).toHaveURL('/test2?q=hello+world'); }); test('submits form when pressing submit action', async ({ page }) => { - await page.locator('[data-test-id="test1"]').locator('[type=submit]').first().click(); + await page.getByTestId('test1').locator('[type=submit]').first().click(); await expect(page, 'submit action should only be triggered if there is a value').toHaveURL('/'); - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().fill('hello world'); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .first() - .locator('[type=submit]') - .click(); + await page.getByTestId('test1').getByRole('combobox').first().fill('hello world'); + await page.getByTestId('test1').getByRole('dialog').first().locator('[type=submit]').click(); await expect(page).toHaveURL('/test1?q=hello+world'); }); test('clears input form when pressing clear action', async ({ page }) => { - await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click(); - await page - .locator('[data-test-id="test1"]') - .getByRole('dialog') - .getByRole('combobox') - .fill('Hello world'); - await expect( - await page.locator('[data-test-id="test1"]').getByRole('combobox').first() - ).toHaveText('Hello world'); - await page.locator('[data-test-id="test1"]').getByRole('dialog').locator('[type=reset]').click(); + await page.getByTestId('test1').getByRole('combobox').first().click(); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').fill('Hello world'); + await expect(await page.getByTestId('test1').getByRole('combobox').first()).toHaveText( + 'Hello world' + ); + await page.getByTestId('test1').getByRole('dialog').locator('[type=reset]').click(); await expect( - page.locator('[data-test-id="test1"]').getByRole('dialog').locator('[type=reset]') + page.getByTestId('test1').getByRole('dialog').locator('[type=reset]') ).not.toBeVisible(); - await expect( - await page.locator('[data-test-id="test1"]').getByRole('combobox').first() - ).toHaveText('Search'); + await expect(await page.getByTestId('test1').getByRole('combobox').first()).toHaveText('Search'); +}); + +test('has support for persistent items', async ({ page }) => { + await page.getByTestId('test1').getByRole('combobox').first().click(); + await expect(page.getByTestId('persistent-item')).toBeVisible(); + await page.getByTestId('test1').getByRole('dialog').getByRole('combobox').fill('Hello world'); + await expect(page.getByTestId('persistent-item')).toBeVisible(); }); diff --git a/packages/supersearch/src/lib/components/SuperSearch.svelte b/packages/supersearch/src/lib/components/SuperSearch.svelte index 6a758de38..581fdabb3 100644 --- a/packages/supersearch/src/lib/components/SuperSearch.svelte +++ b/packages/supersearch/src/lib/components/SuperSearch.svelte @@ -38,6 +38,7 @@ resultItem?: Snippet< [ResultItem, (cellIndex: number) => string, (cellIndex: number) => boolean, number] >; + persistentItem?: Snippet<[(cellIndex: number) => string, (cellIndex: number) => boolean]>; defaultRow?: number; toggleWithKeyboardShortcut?: boolean; debouncedWait?: number; @@ -61,6 +62,7 @@ closeAction: closeActionSnippet, closeActionMediaQueryString = 'max-width: 640px', // defines when the back/close action should be visible (only shown when expanded) resultItem = fallbackResultItem, + persistentItem, toggleWithKeyboardShortcut = false, defaultRow = 0, debouncedWait = 300 @@ -140,7 +142,7 @@ 'aria-controls': `${id}-grid`, // identifies the popup element that lists suggested values 'aria-multiline': 'false', ...(includeAriaActiveDescendant && { - 'aria-activedescendant': `${id}-result-item-${activeRowIndex}x${activeColIndex}` // enables assistive technologies to know which element the application regards as focused while DOM focus remains on the input element + 'aria-activedescendant': `${id}-item-${activeRowIndex}x${activeColIndex}` // enables assistive technologies to know which element the application regards as focused while DOM focus remains on the input element }) }) ); @@ -227,9 +229,9 @@ } if (event.key === 'Enter') { - /* Fire click event if result item cell is focused */ + /* Fire click event if item cell is focused */ if (activeRowIndex >= 0 && search.data) { - document?.getElementById(`${id}-result-item-${activeRowIndex}x${activeColIndex}`)?.click(); + document?.getElementById(`${id}-item-${activeRowIndex}x${activeColIndex}`)?.click(); hideExpandedSearch(); } else if (value.length) { submitClosestForm(); @@ -311,7 +313,7 @@ /** * TODO: Ensure the input is in view - * const activeCellElement = document.getElementById(`${id}-result-item-${activeRowIndex}x${activeColIndex}`); + * const activeCellElement = document.getElementById(`${id}-item-${activeRowIndex}x${activeColIndex}`); * * if (!isElementInView(activeCellElement)) { * activeCellElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -450,24 +452,33 @@ {@render submitAction()}