Skip to content

Commit

Permalink
feat(supersearch): Add persistent item support (LWS-289) (#1205)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
johanbissemattsson authored Jan 20, 2025
1 parent 114f30a commit be8ef7b
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 130 deletions.
3 changes: 2 additions & 1 deletion packages/supersearch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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).

Expand Down
179 changes: 70 additions & 109 deletions packages/supersearch/e2e/supersearch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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(
Expand All @@ -163,69 +132,61 @@ 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"');
});

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();
});
Loading

0 comments on commit be8ef7b

Please sign in to comment.