Skip to content

Commit 6ee035c

Browse files
feat(supersearch, lxl-web): Control supersearch expanded state using history (#1569)
* Add support for shallow routing in supersearch Enables controlling the expanded state using the History API (e.g. closing the dialog when pressing back) * Fix navigation when pressing escape * Intercept link clicks in expanded supersearch * Update readme
1 parent bbb8d2a commit 6ee035c

11 files changed

Lines changed: 136 additions & 11 deletions

File tree

‎lxl-web/src/app.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ declare global {
3232
siteName?: string;
3333
}
3434
interface PageState {
35+
expandedSuperSearch?: boolean;
3536
expandedInstances?: string[];
3637
holdings?: string;
3738
adjecentSearchResults?: AdjecentSearchResult[];

‎lxl-web/src/lib/components/supersearch/SuperSearchWrapper.svelte‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,10 @@
314314
});
315315
}
316316
317+
function submit(form: HTMLFormElement) {
318+
superSearch?.submit(form);
319+
}
320+
317321
function addQualifierKey(qualifierKey: string) {
318322
superSearch?.resetData();
319323
showExpandedSearch(); // keep dialog open (since 'regular' search is hidden on mobile)
@@ -440,6 +444,7 @@
440444
searchContext.showExpandedSearch = showExpandedSearch;
441445
searchContext.hideExpandedSearch = hideExpandedSearch;
442446
searchContext.changeQuery = changeQuery;
447+
searchContext.submit = submit;
443448
searchContext.isMounted = true;
444449
});
445450
@@ -478,6 +483,7 @@
478483
}}
479484
transformFn={handleTransform}
480485
extensions={[derivedLxlQualifierPlugin]}
486+
shallowRouting
481487
toggleWithKeyboardShortcut
482488
wrappingArrowKeyNavigation
483489
defaultInputCol={undefined}
@@ -591,6 +597,7 @@
591597
getCellId,
592598
isFocusedRow,
593599
isFocusedCell
600+
// gotoAfterCollapse
594601
})}
595602
{@const inputRowIndex = 0}
596603
{@const qualifiersRowIndex = showAddQualifiers ? 1 : -1}

‎lxl-web/src/lib/contexts/search.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const [getSearchContext, setSearchContext] = createContext<{
66
showExpandedSearch: (options: ShowExpandedSearchOptions) => void;
77
hideExpandedSearch: () => void;
88
changeQuery: (params: ChangeQueryParams) => void;
9+
submit: (form: HTMLFormElement) => void;
910
initialStateBeforeMount?: {
1011
value: string;
1112
selection?: { anchor: number | null | undefined; head: number | null | undefined };

‎lxl-web/src/routes/(app)/[[lang=lang]]/+layout.svelte‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
showExpandedSearch: () => {},
2222
hideExpandedSearch: () => {},
2323
changeQuery: () => {},
24+
submit: () => {},
2425
initialStateBeforeMount: undefined,
2526
isMounted: false
2627
});

‎lxl-web/src/routes/(app)/[[lang=lang]]/AppBar.svelte‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@
135135
shadowObserver?.disconnect();
136136
}
137137
138+
function handleSubmit(event: SubmitEvent) {
139+
if (document.getElementById('supersearch-dialog')?.hasAttribute('open') && event.target) {
140+
event.preventDefault();
141+
searchContext.submit(event.target as HTMLFormElement);
142+
}
143+
}
144+
138145
beforeNavigate(() => {
139146
closeExpandedMenu();
140147
});
@@ -297,7 +304,12 @@
297304
'hidden target:flex has-[dialog:open]:h-0 lg:flex lg:has-[dialog:open]:h-fit' // enable toggling using target/anchor (so it also works when JavaScript is disabled)
298305
]}
299306
>
300-
<form id="search-form" action={findActionUrl} class="mx-auto w-full min-w-0">
307+
<form
308+
id="search-form"
309+
action={findActionUrl}
310+
onsubmit={handleSubmit}
311+
class="mx-auto w-full min-w-0"
312+
>
301313
{#if isHomeRoute}
302314
<hgroup
303315
class="absolute my-3 px-3 leading-snug @xl:mt-6 lg:@xl:my-3 lg:@xl:px-3 @3xl:leading-normal lg:@3xl:my-3 lg:@3xl:px-4 @5xl:my-4"

‎packages/supersearch/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ To use `supersearch` in a non-Svelte project ...
3838
| `defaultInputCol` | `number` | An integer defining which input item column should be focused by default (use `-1` if no column should be focused). | `-1` |
3939
| `defaultResultItemCol` | `number` | An integer defining which result item column should be focused by default (use `-1` if no column should be focused). | `0` |
4040

41+
| `shallowRouting` | `boolean` | Controls if the component should support the expanded state using the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) (e.g. closing the dialog when pressing back). Links in the expanded search are intercepted so they first go back (collapsing the dialog) before navigating to the `href` attribute of the link. Programmatic usage of `goto` should be replaced with the provided `gotoAfterCollapse` function. | `false` |
4142
| `toggleWithKeyboardShortcut` | `boolean` | Controls if expanded search should be togglable using `cmd+k`(macOS) and `ctrl+k` (Linux/Windows) | `false` |
4243
| `debouncedWait` | `number` | The wait time, in milliseconds that debounce function should wait between invocated search queries. | `300` |
4344
| `getDebouncedWait` | `DebouncedWaitFunction` | A function `(query: string) => number \| null` for dynamic debounce timing based on query length; return `null` to not fetch at all. | `undefined` |

‎packages/supersearch/e2e/supersearch.spec.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,16 @@ test('exports isLoading and hasResults as bindable props (should be treated as r
377377
await expect(page.getByTestId('is-loading-bind')).toHaveText('is loading: false');
378378
await expect(page.getByTestId('has-data-bind')).toHaveText('has data: true');
379379
});
380+
381+
test('shallow routing enables controlling expanded state using the history API', async ({
382+
page
383+
}) => {
384+
const initialUrl = page.url;
385+
await page.getByRole('combobox').click();
386+
await expect(page.getByRole('dialog')).toBeVisible();
387+
await page.goBack();
388+
await expect(page.url).toEqual(initialUrl);
389+
await expect(page.getByRole('dialog')).not.toBeVisible();
390+
await page.goForward();
391+
await expect(page.getByRole('dialog')).toBeVisible();
392+
});

‎packages/supersearch/src/app.d.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ declare global {
55
// interface Error {}
66
// interface Locals {}
77
// interface PageData {}
8-
// interface PageState {}
8+
interface PageState {
9+
expandedSuperSearch?: boolean;
10+
}
911
// interface Platform {}
1012
}
1113
}

‎packages/supersearch/src/lib/components/SuperSearch.svelte‎

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script lang="ts">
22
import { onMount, onDestroy, type Snippet } from 'svelte';
33
import { BROWSER } from 'esm-env';
4+
import { page } from '$app/state';
5+
import { goto, pushState } from '$app/navigation';
46
import CodeMirror, {
57
type ChangeCodeMirrorEvent,
68
type SelectCodeMirrorEvent,
@@ -35,6 +37,7 @@
3537
getCellId: (rowIndex: number, cellIndex: number) => string | undefined;
3638
isFocusedCell: (rowIndex: number, cellIndex: number) => boolean;
3739
isFocusedRow: (rowIndex: number) => boolean;
40+
gotoAfterCollapse: (url: string | URL) => void;
3841
};
3942
4043
interface Props {
@@ -69,6 +72,7 @@
6972
onclickSubmit: (event: MouseEvent) => void;
7073
onclickClear: (event: MouseEvent) => void;
7174
onclickClose: (event: MouseEvent) => void;
75+
gotoAfterCollapse?: (url: string | URL) => void;
7276
}
7377
]
7478
>;
@@ -80,6 +84,7 @@
8084
getCellId: (cellIndex: number) => string;
8185
isFocusedCell: (cellIndex: number) => boolean;
8286
rowIndex: number;
87+
gotoAfterCollapse?: (url: string | URL) => void;
8388
}
8489
]
8590
>;
@@ -89,6 +94,7 @@
8994
defaultResultCol?: number;
9095
toggleWithKeyboardShortcut?: boolean;
9196
wrappingArrowKeyNavigation?: boolean;
97+
shallowRouting?: boolean;
9298
debouncedWait?: number;
9399
getDebouncedWait?: DebouncedWaitFunction;
94100
selection?: Selection;
@@ -128,6 +134,7 @@
128134
loadingIndicator,
129135
toggleWithKeyboardShortcut = false,
130136
wrappingArrowKeyNavigation = false,
137+
shallowRouting = false,
131138
defaultInputCol = -1,
132139
defaultResultRow = 0,
133140
defaultResultCol = 0,
@@ -150,6 +157,7 @@
150157
let expanded = $state(false);
151158
let activeRowIndex: number = $state(0);
152159
let activeColIndex: number = $state(-1);
160+
let gotoAfterCollapseUrl: string | URL | undefined = $state();
153161
let prevValue: string = value;
154162
155163
let allowArrowKeyCursorHandling: { vertical: boolean; horizontal: boolean } = $state({
@@ -362,6 +370,36 @@
362370
});
363371
}
364372
373+
export async function submit(form: HTMLFormElement) {
374+
hideExpandedSearch();
375+
const formData = new FormData(form);
376+
const formParams = new URLSearchParams(formData as unknown as Record<string, string>);
377+
const actionUrl = new URL(form.action);
378+
gotoAfterCollapse(`${actionUrl.href}?${formParams.toString()}`);
379+
}
380+
381+
async function gotoAfterCollapse(url: string | URL) {
382+
gotoAfterCollapseUrl = url;
383+
history.back();
384+
}
385+
386+
async function handlePopState(event: PopStateEvent) {
387+
if (dialog?.open) {
388+
hideExpandedSearch();
389+
}
390+
if (gotoAfterCollapseUrl) {
391+
const url = gotoAfterCollapseUrl;
392+
gotoAfterCollapseUrl = undefined;
393+
await goto(url);
394+
collapsedEditorView?.focus();
395+
} else if (
396+
page.state.expandedSuperSearch &&
397+
event.state['sveltekit:states']?.expandedSuperSearch // a little bit hacky way to ensure the dialog doesn't flicker when navigating forward
398+
) {
399+
showExpandedSearch({ focusRow: 0, preventPushState: true });
400+
}
401+
}
402+
365403
export function showExpandedSearch(options?: ShowExpandedSearchOptions) {
366404
if (!expanded) {
367405
expandedEditorView?.dispatch({
@@ -373,6 +411,11 @@
373411
dialog?.showModal();
374412
expanded = true;
375413
onexpand?.({ windowPageYOffset: window.pageYOffset });
414+
if (shallowRouting) {
415+
if (!options?.preventPushState) {
416+
pushState('', { ...page.state, expandedSuperSearch: true });
417+
}
418+
}
376419
}
377420
setDefaultRowAndCols({ focusRow: options?.focusRow });
378421
if (!options?.focusRow || options.focusRow < 1) {
@@ -420,8 +463,10 @@
420463
: collapsedEditorView?.dom?.closest('form');
421464
422465
if (formElement && formElement instanceof HTMLFormElement) {
466+
if (!shallowRouting) {
467+
hideExpandedSearch();
468+
}
423469
formElement.requestSubmit();
424-
hideExpandedSearch();
425470
}
426471
}
427472
@@ -445,7 +490,11 @@
445490
446491
function handleExpandedKeyDown(event: KeyboardEvent) {
447492
if (event.key === 'Escape') {
448-
hideExpandedSearch();
493+
if (shallowRouting) {
494+
history.back();
495+
} else {
496+
hideExpandedSearch();
497+
}
449498
}
450499
451500
if (event.key === 'Enter') {
@@ -689,7 +738,11 @@
689738
690739
function handleClickOutsideDialog(event: MouseEvent) {
691740
if (event.target === dialog || event.target === event.currentTarget) {
692-
hideExpandedSearch();
741+
if (shallowRouting) {
742+
history.back();
743+
} else {
744+
hideExpandedSearch();
745+
}
693746
}
694747
}
695748
@@ -713,6 +766,19 @@
713766
// }
714767
}
715768
769+
function interceptExpandedLinks(event: MouseEvent) {
770+
const target = event.target as HTMLElement;
771+
const linkElement = target.tagName === 'A' ? target : target.closest('a');
772+
773+
if (linkElement) {
774+
const href = linkElement.getAttribute('href');
775+
if (href) {
776+
event.preventDefault();
777+
gotoAfterCollapse(href);
778+
}
779+
}
780+
}
781+
716782
function handleReset() {
717783
collapsedEditorView?.dispatch({
718784
changes: { from: 0, to: value.length, insert: '' },
@@ -800,6 +866,14 @@
800866
collapsedEditorView.focus();
801867
}
802868
});
869+
870+
const handleClickClose = $derived(() => {
871+
if (shallowRouting) {
872+
return history.back();
873+
} else {
874+
hideExpandedSearch();
875+
}
876+
});
803877
</script>
804878

805879
{#snippet fallbackExpandedContent({ search }: { search: ReturnType<typeof useSearchRequest> })}
@@ -863,6 +937,7 @@
863937
/>
864938
{/snippet}
865939

940+
<svelte:window onpopstate={handlePopState} />
866941
<div role="presentation" onkeydown={handleCollapsedKeyDown} {id}>
867942
<div class="supersearch-combobox">
868943
{@render inputRow?.({
@@ -873,7 +948,7 @@
873948
isFocusedRow: () => activeRowIndex === -1,
874949
onclickSubmit: handleClickSubmit,
875950
onclickClear: handleReset,
876-
onclickClose: hideExpandedSearch
951+
onclickClose: handleClickClose
877952
})}
878953
<textarea {value} {name} {form} hidden readonly></textarea>
879954
</div>
@@ -882,7 +957,7 @@
882957
class="supersearch-dialog"
883958
id={`${id}-dialog`}
884959
bind:this={dialog}
885-
closedby="any"
960+
closedby="none"
886961
tabindex="-1"
887962
onclose={() => hideExpandedSearch()}
888963
>
@@ -893,7 +968,13 @@
893968
onclick={handleClickOutsideDialog}
894969
>
895970
<div class="supersearch-dialog-content">
896-
<div class="supersearch-combobox" bind:this={comboboxElement}>
971+
<!-- svelte-ignore a11y_click_events_have_key_events -->
972+
<!-- svelte-ignore a11y_no_static_element_interactions -->
973+
<div
974+
class="supersearch-combobox"
975+
bind:this={comboboxElement}
976+
onclick={interceptExpandedLinks}
977+
>
897978
{@render inputRow?.({
898979
expanded: true,
899980
inputField: expandedInputSnippet,
@@ -902,18 +983,22 @@
902983
isFocusedRow: () => activeRowIndex === 0,
903984
onclickSubmit: handleClickSubmit,
904985
onclickClear: handleReset,
905-
onclickClose: hideExpandedSearch
986+
onclickClose: handleClickClose,
987+
gotoAfterCollapse
906988
})}
907989
</div>
908-
<div id={`${id}-grid`} role="grid">
990+
<!-- svelte-ignore a11y_click_events_have_key_events -->
991+
<!-- svelte-ignore a11y_interactive_supports_focus -->
992+
<div id={`${id}-grid`} role="grid" onclick={interceptExpandedLinks}>
909993
{@render expandedContent({
910994
search,
911995
resultsSnippet,
912996
resultsCount: resultItemRows?.length,
913997
getCellId: (rowIndex: number, colIndex: number) => `${id}-item-${rowIndex}x${colIndex}`,
914998
isFocusedCell: (rowIndex: number, colIndex: number) =>
915999
rowIndex === activeRowIndex && colIndex === activeColIndex,
916-
isFocusedRow: (rowIndex: number) => rowIndex === activeRowIndex
1000+
isFocusedRow: (rowIndex: number) => rowIndex === activeRowIndex,
1001+
gotoAfterCollapse
9171002
})}
9181003
</div>
9191004
</div>

‎packages/supersearch/src/lib/types/superSearch.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ResultItem {
1818
export type ShowExpandedSearchOptions = {
1919
cursorAtEnd?: boolean;
2020
focusRow?: number;
21+
preventPushState?: boolean;
2122
};
2223

2324
export type DebouncedWaitFunction = (query: string) => number | null;

0 commit comments

Comments
 (0)