Skip to content

Commit

Permalink
feat(supersearch): Keyboard handling (LWS-277) (#1197)
Browse files Browse the repository at this point in the history
* Support toggling expanded search using keyboard shortcut

* Add keyboard handling for rows and cells in expanded search

Adheres to the Combobox Pattern in W3Cs ARIA Authoring Practice Guide (see: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)

* Use esm-env instead of SvelteKit-specific $app/environment

* Update readme

* Add tests and emove redundant test div locators

* Remove unused submitFormOnEnterKey util

* Implement keyboard handling in lxl-web

* Fix debouncedWait prop

* Reset data when value changes
  • Loading branch information
johanbissemattsson authored Jan 9, 2025
1 parent b1f87dc commit cd81564
Show file tree
Hide file tree
Showing 10 changed files with 540 additions and 148 deletions.
15 changes: 12 additions & 3 deletions lxl-web/src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import addDefaultSearchParams from '$lib/utils/addDefaultSearchParams';
import getSortedSearchParams from '$lib/utils/getSortedSearchParams';
import getLabelFromMappings from '$lib/utils/getLabelsFromMapping.svelte';
import { relativizeUrl } from '$lib/utils/http';
import type { DisplayMapping } from '$lib/types/search';
import BiSearch from '~icons/bi/search';
import { lxlQuery } from 'codemirror-lang-lxlquery';
Expand Down Expand Up @@ -99,11 +100,19 @@
transformFn={handleTransform}
paginationQueryFn={handlePaginationQuery}
extensions={[derivedLxlQualifierPlugin]}
toggleWithKeyboardShortcut
comboboxAriaLabel={$page.data.t('search.search')}
defaultRow={-1}
>
{#snippet resultItem(item)}
<button type="button">
{#snippet resultItem(item, getCellId, isFocusedCell)}
<a
href={relativizeUrl(item['@id'])}
role="gridcell"
id={getCellId(0)}
class:focused-cell={isFocusedCell(0)}
>
<h2>{item?.heading}</h2>
</button>
</a>
{/snippet}
</SuperSearch>
{:else}
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 37 additions & 14 deletions packages/supersearch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,50 @@ To use `supersearch` in a non-Svelte project ...

## Properties

| Property | Type | Description | Default value |
| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------- | ------------- |
| `name` | `string` | A string specifying a name for the form control. | `undefined` |
| `value` | `string` | The value that will be displayed and edited inside the component. | `""` |
| `form` | `string` | A string matching the `id` of a `<form>` element. | `undefined` |
| `language` | `LanguageSupport` | The language extension that will parse and highlight the value. | `undefined` |
| `placeholder` | `string` | A brief hint which is shown when value is empty. | `""` |
| `endpoint` | `string` or `URL` | The endpoint from which the component should fetch data from (used together with `queryFn`). | `undefined` |
| `queryFn` | `QueryFunction` | A function that converts `value` to `URLSearchParams` (which will be appended to the endpoint). | `undefined` |
| `paginationQueryFn` | `PaginationQueryFunction` | A function which should return `URLSearchParams` used for querying more paginated data (if available) | `undefined` |
| `transformFn` | `TransformFunction` | A generic helper function which can be used to transform data fetched from the endpoint. | `undefined` |
| `extensions` | `Extension[]` | A list of extensions which should extend the default extensions. | `[]` |
| `resultItem` | `Snippet<[ResultItem]>` | A [Snippet](https://svelte.dev/docs/svelte/snippet) used for customized rendering of result items. | `undefined` |
| `debouncedWait` | `number` | The wait time, in milliseconds that debounce function should wait between invocated search queries. | `300` |
| Property | Type | Description | Default value |
| ---------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `id` | `string` | A string defining a identifier which must be unique in the whole document. | `"supersearch"` |
| `name` | `string` | A string specifying a name for the form control. | `undefined` |
| `value` | `string` | The value that will be displayed and edited inside the component. | `""` |
| `form` | `string` | A string matching the `id` of a `<form>` element. | `undefined` |
| `language` | `LanguageSupport` | The language extension that will parse and highlight the value. | `undefined` |
| `placeholder` | `string` | A brief hint which is shown when value is empty. | `""` |
| `endpoint` | `string` or `URL` | The endpoint from which the component should fetch data from (used together with `queryFn`). | `undefined` |
| `queryFn` | `QueryFunction` | A function that converts `value` to `URLSearchParams` (which will be appended to the endpoint). | `undefined` |
| `paginationQueryFn` | `PaginationQueryFunction` | A function which should return `URLSearchParams` used for querying more paginated data (if available) | `undefined` |
| `transformFn` | `TransformFunction` | A generic helper function which can be used to transform data fetched from the endpoint. | `undefined` |
| `extensions` | `Extension[]` | A list of extensions which should extend the default extensions. | `[]` |
| `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` |
| `defaultFocusedRow` | `number` | An integer defining which result item row should be focused by default (use `-1` if no row should be focused). | `0` |
| `toggleWithKeyboardShortcut` | `boolean` | Controls if expanded search should be togglable using `cmd+k`(macOS) and `ctrl+k` (Linux/Windows) | `false` |
| `debouncedWait` | `number` | The wait time, in milliseconds that debounce function should wait between invocated search queries. | `300` |

&nbsp;
Supersearch also exports a `lxlQualifierPlugin` that can be used (passed to the extensions prop) if you want atomic, stylable, removable, labeled pills from some key-value pairs in your editor. This requires:

- Your language exporting `Qualifier` nodes consisting of `QualifierKey`, `QualifierOperator` and `QualifierValue` (i.e `key:value`).
- Passing a function of type `GetLabelFunction`, returning labels to be displayed, an optional remove link and an optional `invalid` flag, which enables styling of invalid queries.

## Implementing the component in your project

TODO: Write more documentation here...

### Custom Result items

Custom result items can be defined as a [Snippet](https://svelte.dev/docs/svelte/snippet) passed as a `resultItem` prop.

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.

3. `isFocusedCell[cellIndex: number]` - A helper function which returns a boolean value if the cell is focused (useful for styling).

4. `rowIndex` - Integer defining the current row index of the result item.

Each interactable cell element (button or links) should have the `role="gridcell"` attribute and an ID generated by the `getCellId` helper function from the snippet params. Some sort of focused styling should also be applied using `isFocusedCell` from the snippet params.

## Developing

Install dependencies with `npm install` and start a development server:
Expand Down
158 changes: 122 additions & 36 deletions packages/supersearch/e2e/supersearch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,150 @@ test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test('submits closest form on enter key press', async ({ page }) => {
await page
.locator('[data-test-id="test1"]')
.getByRole('textbox')
.locator('div')
.fill('hello world');
await page.keyboard.press('Enter');
await expect(page).toHaveURL('/test1?q=hello+world');
});

test('submits form identified by form attribute on enter key press', async ({ page }) => {
await page
.locator('[data-test-id="test2"]')
.getByRole('textbox')
.locator('div')
.fill('hello world');
await page.keyboard.press('Enter');
await expect(page).toHaveURL('/test2?q=hello+world');
});

test('prevents new line characters (e.g. when pasting multi-lined text', async ({
test('prevents new line characters (e.g. when pasting multi-lined text)', async ({
page,
context
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.locator('[data-test-id="test1"]').getByRole('textbox').first().locator('div').click();
await page.locator('[data-test-id="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('textbox').first().locator('div')
).toHaveText('One two three');
await expect(page.locator('[data-test-id="test1"]').getByRole('combobox').first()).toHaveText(
'One two three'
);
});

test('expanded search is closable', async ({ page }) => {
await page.locator('[data-test-id="test1"]').getByRole('textbox').locator('div').click();
await page.locator('[data-test-id="test1"]').getByRole('combobox').click();
await expect(page.locator('[data-test-id="test1"]').getByRole('dialog').first()).toBeVisible();
await page.keyboard.press('Escape');
await expect(
page.locator('[data-test-id="test1"]').getByRole('dialog').first(),
'by pressing the Escape key'
).not.toBeVisible();
await page.locator('[data-test-id="test1"]').getByRole('textbox').locator('div').click();
await page.locator('[data-test-id="test1"]').getByRole('combobox').click();
await page.mouse.click(0, 0);
await expect(
page.locator('[data-test-id="test1"]').getByRole('dialog').first(),
'by clicking outside'
).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 expect(page.locator('[data-test-id="test2"]').getByRole('combobox').first()).toBeFocused();
await page.keyboard.press('ControlOrMeta+k');
await expect(page.locator('[data-test-id="test1"]').getByRole('dialog').first()).toBeVisible();
await page.keyboard.press('ControlOrMeta+k');
await expect(
page.locator('[data-test-id="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 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/);
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('ArrowRight');
await expect(comboboxElement).toHaveAttribute(
'aria-activedescendant',
'supersearch-result-item-1x1'
);
await page.keyboard.press('ArrowLeft');
await expect(comboboxElement).toHaveAttribute(
'aria-activedescendant',
'supersearch-result-item-1x0'
);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await expect(comboboxElement).toHaveAttribute(
'aria-activedescendant',
'supersearch-result-item-1x2'
);
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');
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/);
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 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'),
'ensure focus is kept inside result items when shift-tabbing on first row'
).toHaveClass(/focused-cell/);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
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'),
'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/);
});

test('syncs collapsed and expanded editor views', async ({ page }) => {
await page.locator('[data-test-id="test1"]').getByRole('textbox').locator('div').click();
await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click();
await page
.locator('[data-test-id="test1"]')
.getByRole('dialog')
.getByRole('textbox')
.locator('div')
.getByRole('combobox')
.fill('Hello world');
await page
.locator('[data-test-id="test1"]')
.getByRole('dialog')
.getByRole('textbox')
.getByRole('combobox')
.selectText();
await page
.locator('[data-test-id="test1"]')
.getByRole('dialog')
.getByRole('textbox')
.getByRole('combobox')
.press('Escape');
await expect(
await page.locator('[data-test-id="test1"]').getByRole('textbox').locator('div'),
await page.locator('[data-test-id="test1"]').getByRole('combobox').first(),
'contents should be synced'
).toHaveText('Hello world');
expect(
Expand All @@ -85,13 +156,20 @@ test('syncs collapsed and expanded editor views', async ({ page }) => {
).toBe('Hello world');
});

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.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page).toHaveURL('/test1#supersearch-result-item-1x0');
});

test('fetches and displays paginated results', async ({ page }) => {
await page.locator('[data-test-id="test1"]').getByRole('textbox').locator('div').click();
await page.locator('[data-test-id="test1"]').getByRole('combobox').first().click();
await page
.locator('[data-test-id="test1"]')
.getByRole('dialog')
.getByRole('textbox')
.locator('div')
.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);
Expand All @@ -105,3 +183,11 @@ test('fetches and displays paginated results', async ({ page }) => {
'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.keyboard.press('Enter');
await expect(page).toHaveURL('/test2?q=hello+world');
});
2 changes: 1 addition & 1 deletion packages/supersearch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"esm-env": "^1.1.4",
"esm-env": "^1.2.1",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-css-order": "^2.1.2",
Expand Down
Loading

0 comments on commit cd81564

Please sign in to comment.