Skip to content

Commit

Permalink
Add keyboard handling for rows and cells in expanded search
Browse files Browse the repository at this point in the history
Adheres to the Combobox Pattern in W3Cs ARIA Authoring Practice Guide (see: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
  • Loading branch information
johanbissemattsson committed Dec 20, 2024
1 parent 6dd1ad8 commit d07f14b
Show file tree
Hide file tree
Showing 3 changed files with 258 additions and 59 deletions.
50 changes: 35 additions & 15 deletions packages/supersearch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,41 @@ 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` |
| `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` |
| 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. | `""` |
| `id` | `string` | A string defining a identifier which must be unique in the whole document. | `"supersearch"` |
| `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` |

## 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.

## Developing

Expand Down
198 changes: 164 additions & 34 deletions packages/supersearch/src/lib/components/SuperSearch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { standardKeymap } from '@codemirror/commands';
interface Props {
id?: string;
name: string;
value?: string;
form?: string;
Expand All @@ -27,11 +28,15 @@
paginationQueryFn?: PaginationQueryFunction;
transformFn?: TransformFunction;
extensions?: Extension[];
resultItem?: Snippet<[ResultItem]>;
resultItem?: Snippet<
[ResultItem, (cellIndex: number) => string, (cellIndex: number) => boolean, number]
>;
defaultResultRow?: number;
toggleWithKeyboardShortcut?: boolean;
}
let {
id = 'supersearch',
name,
value = $bindable(''),
form,
Expand All @@ -43,16 +48,23 @@
transformFn,
extensions = [],
resultItem = fallbackResultItem,
toggleWithKeyboardShortcut = false
toggleWithKeyboardShortcut = false,
defaultResultRow = 0
}: Props = $props();
let collapsedEditorView: EditorView | undefined = $state();
let expandedEditorView: EditorView | undefined = $state();
let expanded = $state(false);
let dialog: HTMLDialogElement | undefined = $state();
let activeRowIndex: number = $state(0);
let activeColIndex: number = $state(0);
let placeholderCompartment = new Compartment();
let prevPlaceholder = placeholder;
let collapsedContentAttributesCompartment = new Compartment();
let expandedContentAttributesCompartment = new Compartment();
let search = useSearchRequest({
endpoint,
queryFn,
Expand All @@ -75,6 +87,43 @@
...extensions
];
let collapsedContentAttributes = $derived(
EditorView.contentAttributes.of({
'aria-haspopup': 'dialog', // indicates the availability and type of interactive popup element that can be triggered by the element
'aria-controls': `${id}-dialog`, // identifies the popup element
'aria-expanded': expanded.toString() // indicates if the popup element is open
})
);
let expandedContentAttributes = $derived(
EditorView.contentAttributes.of({
id: `${id}-content`,
role: 'combobox', // identifies the element as a combobox
'aria-haspopup': 'grid', // indicates that the combobox can popup a grid to suggest values
'aria-expanded': 'true', // indicates that the popup element is displayed
'aria-autocomplete': 'list', // indicates that the autocomplete behavior of the input is to suggest a list of possible values in a popup
'aria-controls': `${id}-grid`, // identifies the popup element that lists suggested values
...(activeRowIndex >= 0 && {
'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
})
})
);
/* svelte-ignore state_referenced_locally */
const initialCollapsedContentAttributes = collapsedContentAttributes; // initial value is needed to prevent unnecessary updates when using compartments
// svelte-ignore state_referenced_locally
const initialExpandedContentAttributes = expandedContentAttributes;
let collapsedExtensions = $derived([
...extensionsWithDefaults,
collapsedContentAttributesCompartment.of(initialCollapsedContentAttributes)
]);
let expandedExtensions = $derived([
...extensionsWithDefaults,
expandedContentAttributesCompartment.of(initialExpandedContentAttributes)
]);
function handleClickCollapsed() {
if (!dialog?.open) showExpandedSearch();
}
Expand All @@ -84,6 +133,8 @@
showExpandedSearch();
}
value = event.value;
activeRowIndex = defaultResultRow;
activeColIndex = 0;
}
function showExpandedSearch() {
Expand All @@ -92,6 +143,7 @@
});
dialog?.showModal();
expandedEditorView?.focus();
expanded = true;
}
function hideExpandedSearch() {
Expand All @@ -100,6 +152,7 @@
});
dialog?.close();
collapsedEditorView?.focus();
expanded = false;
}
function handleKeyDown(event: KeyboardEvent) {
Expand All @@ -110,22 +163,77 @@
/**
* Handle keyboard navigation between focusable elements in expanded search
*/
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const focusableElements = Array.from(
(event.target as HTMLElement)
.closest('dialog')
?.querySelectorAll(`.cm-content, nav button`) || []
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'Tab'
) {
const rows = Array.from(
dialog?.querySelectorAll(':scope nav:first-of-type [role=row]') || []
);
const activeIndex = document.activeElement
? focusableElements?.indexOf(document.activeElement)
: -1;
if (activeIndex > -1) {
event.preventDefault();
(
focusableElements[
event.key === 'ArrowUp' ? activeIndex - 1 : activeIndex + 1
] as HTMLElement
)?.focus();
const getColsInActiveRow = (activeRowIndex: number) =>
rows[activeRowIndex].querySelectorAll(':scope button, :scope a');
if (rows.length) {
switch (event.key) {
case 'ArrowUp':
if (activeRowIndex > 0) {
activeRowIndex--;
activeColIndex = Math.min(
activeColIndex,
getColsInActiveRow(activeRowIndex).length - 1
);
}
break;
case 'ArrowDown':
if (activeRowIndex < rows.length - 1) {
activeRowIndex++;
activeColIndex = Math.min(
activeColIndex,
getColsInActiveRow(activeRowIndex).length - 1
);
}
break;
case 'ArrowLeft':
if (activeColIndex > 0) {
activeColIndex--;
}
break;
case 'ArrowRight':
if (activeColIndex < getColsInActiveRow(activeRowIndex).length - 1) {
activeColIndex++;
}
break;
case 'Tab':
if (event.shiftKey) {
if (activeColIndex == 0) {
activeRowIndex--;
activeColIndex = getColsInActiveRow(activeRowIndex).length - 1;
} else {
activeColIndex--;
}
} else {
if (activeColIndex < getColsInActiveRow(activeRowIndex).length - 1) {
activeColIndex++;
} else {
activeRowIndex++;
activeColIndex = 0;
}
}
break;
}
/**
* TODO: Ensure the input is in view
* const activeCellElement = document.getElementById(`${id}-result-item-${activeRowIndex}x${activeColIndex}`);
*
* if (!isElementInView(activeCellElement)) {
* activeCellElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
* }
*/
}
}
}
Expand Down Expand Up @@ -172,6 +280,18 @@
prevPlaceholder = placeholder;
}
});
$effect(() => {
collapsedEditorView?.dispatch({
effects: collapsedContentAttributesCompartment.reconfigure(collapsedContentAttributes)
});
});
$effect(() => {
expandedEditorView?.dispatch({
effects: expandedContentAttributesCompartment.reconfigure(expandedContentAttributes)
});
});
</script>

{#snippet fallbackResultItem(item: ResultItem)}
Expand All @@ -180,18 +300,18 @@

<CodeMirror
{value}
extensions={extensionsWithDefaults}
extensions={collapsedExtensions}
onclick={handleClickCollapsed}
onchange={handleChangeCodeMirror}
bind:editorView={collapsedEditorView}
syncedEditorView={expandedEditorView}
/>
<textarea {value} {name} {form} hidden readonly></textarea>
<dialog bind:this={dialog} onclose={hideExpandedSearch}>
<dialog id={`${id}-dialog`} bind:this={dialog} onclose={hideExpandedSearch}>
<div role="presentation" onkeydown={handleKeyDown}>
<CodeMirror
{value}
extensions={extensionsWithDefaults}
extensions={expandedExtensions}
onchange={handleChangeCodeMirror}
bind:editorView={expandedEditorView}
syncedEditorView={collapsedEditorView}
Expand All @@ -202,33 +322,43 @@
(Array.isArray(search.paginatedData) &&
search.paginatedData.map((page) => page.items).flat()) ||
search.data?.items}
<ul>
{#each resultItems as item}
<li>
{@render resultItem?.(item)}
</li>
<div id={`${id}-grid`} role="grid">
{#each resultItems as item, rowIndex}
<div role="row" class:focused={activeRowIndex === rowIndex}>
{@render resultItem?.(
item,
(colIndex: number) => `${id}-result-item-${rowIndex}x${colIndex}`,
(colIndex: number) => activeRowIndex === rowIndex && colIndex === activeColIndex,
rowIndex
)}
</div>
{/each}
</ul>
</div>
{/if}
{#if search.isLoading}
Loading...
{:else if search.hasMorePaginatedData}
<button type="button" class="supersearch-show-more" onclick={search.fetchMoreData}>
Load more
</button>
<li>
<button type="button" class="supersearch-show-more" onclick={search.fetchMoreData}>
Load more
{activeRowIndex}x{activeColIndex}
</button>
</li>
{/if}
</nav>
</div>
</dialog>

<style>
ul {
margin: 0;
dialog {
padding: 0;
list-style-type: none;
}
dialog {
padding: 0;
.focused {
background: #ebebeb;
& :global(.focused-cell) {
background: lightgreen;
}
}
</style>
Loading

0 comments on commit d07f14b

Please sign in to comment.