diff --git a/packages/supersearch/README.md b/packages/supersearch/README.md index fefbef0f5..b42c5a801 100644 --- a/packages/supersearch/README.md +++ b/packages/supersearch/README.md @@ -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 `
` 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 `` 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 diff --git a/packages/supersearch/src/lib/components/SuperSearch.svelte b/packages/supersearch/src/lib/components/SuperSearch.svelte index 8eb59a95b..f44d257fd 100644 --- a/packages/supersearch/src/lib/components/SuperSearch.svelte +++ b/packages/supersearch/src/lib/components/SuperSearch.svelte @@ -17,6 +17,7 @@ import { standardKeymap } from '@codemirror/commands'; interface Props { + id?: string; name: string; value?: string; form?: string; @@ -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, @@ -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, @@ -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(); } @@ -84,6 +133,8 @@ showExpandedSearch(); } value = event.value; + activeRowIndex = defaultResultRow; + activeColIndex = 0; } function showExpandedSearch() { @@ -92,6 +143,7 @@ }); dialog?.showModal(); expandedEditorView?.focus(); + expanded = true; } function hideExpandedSearch() { @@ -100,6 +152,7 @@ }); dialog?.close(); collapsedEditorView?.focus(); + expanded = false; } function handleKeyDown(event: KeyboardEvent) { @@ -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' }); + * } + */ } } } @@ -172,6 +280,18 @@ prevPlaceholder = placeholder; } }); + + $effect(() => { + collapsedEditorView?.dispatch({ + effects: collapsedContentAttributesCompartment.reconfigure(collapsedContentAttributes) + }); + }); + + $effect(() => { + expandedEditorView?.dispatch({ + effects: expandedContentAttributesCompartment.reconfigure(expandedContentAttributes) + }); + }); {#snippet fallbackResultItem(item: ResultItem)} @@ -180,18 +300,18 @@ - +
page.items).flat()) || search.data?.items} -
    - {#each resultItems as item} -
  • - {@render resultItem?.(item)} -
  • +
    + {#each resultItems as item, rowIndex} +
    + {@render resultItem?.( + item, + (colIndex: number) => `${id}-result-item-${rowIndex}x${colIndex}`, + (colIndex: number) => activeRowIndex === rowIndex && colIndex === activeColIndex, + rowIndex + )} +
    {/each} -
+
{/if} {#if search.isLoading} Loading... {:else if search.hasMorePaginatedData} - +
  • + +
  • {/if}
    diff --git a/packages/supersearch/src/routes/+page.svelte b/packages/supersearch/src/routes/+page.svelte index 37a33c0d2..863f6765a 100644 --- a/packages/supersearch/src/routes/+page.svelte +++ b/packages/supersearch/src/routes/+page.svelte @@ -51,10 +51,30 @@ extensions={[lxlQualifierPlugin]} toggleWithKeyboardShortcut > - {#snippet resultItem(item)} - + {#snippet resultItem(item, getCellId, isFocusedCell, rowIndex)} +
    + + {#if (rowIndex! > 0 && rowIndex! <= 4) || rowIndex == 9} + + {/if} + {#if (rowIndex! > 0 && rowIndex! < 3) || rowIndex! == 9 || rowIndex! === 4} + C + {/if} +
    {/snippet} @@ -78,10 +98,12 @@ language={lxlQuery} extensions={[lxlQualifierPlugin]} > - {#snippet resultItem(item)} - + {#snippet resultItem(item, index, focusedCellIndex)} +
    + +
    {/snippet} @@ -93,9 +115,32 @@