@classic-homes/theme-svelte 1.8.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/header-search.test.js +76 -0
- package/dist/__tests__/sidebar-collapse.test.js +58 -0
- package/dist/lib/components/HeaderSearch.svelte +110 -38
- package/dist/lib/components/HeaderSearch.svelte.d.ts +4 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +12 -7
- package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +1 -1
- package/dist/lib/stores/sidebar.svelte.d.ts +2 -1
- package/dist/lib/stores/sidebar.svelte.js +5 -1
- package/dist/lib/types/layout.d.ts +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor, within } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { axe } from 'vitest-axe';
|
|
5
|
+
import HeaderSearch from '../lib/components/HeaderSearch.svelte';
|
|
6
|
+
const groups = [
|
|
7
|
+
{
|
|
8
|
+
title: 'Pages',
|
|
9
|
+
items: [
|
|
10
|
+
{ id: 'p1', title: 'Dashboard', href: '/dashboard', icon: 'home' },
|
|
11
|
+
{ id: 'p2', title: 'Documents', href: '/documents' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
title: 'Selections',
|
|
16
|
+
items: [
|
|
17
|
+
{ id: 's1', title: 'Quartz countertop', description: 'Kitchen', category: 'Selection' },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
async function openSearch(props = {}) {
|
|
22
|
+
// A dialog left open by the previous test can leave pointer-events: none on body.
|
|
23
|
+
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
24
|
+
const result = render(HeaderSearch, { props: { results: groups, ...props } });
|
|
25
|
+
await user.click(screen.getByRole('button', { name: 'Search' }));
|
|
26
|
+
const input = await screen.findByRole('combobox');
|
|
27
|
+
return { user, input, ...result };
|
|
28
|
+
}
|
|
29
|
+
describe('HeaderSearch', () => {
|
|
30
|
+
it('renders a heading for each result group', async () => {
|
|
31
|
+
await openSearch();
|
|
32
|
+
const pages = screen.getByRole('group', { name: 'Pages' });
|
|
33
|
+
expect(within(pages).getAllByRole('option')).toHaveLength(2);
|
|
34
|
+
const selections = screen.getByRole('group', { name: 'Selections' });
|
|
35
|
+
expect(within(selections).getByRole('option', { name: /Quartz countertop/ })).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
it('renders flat results without headings', async () => {
|
|
38
|
+
const flat = [{ id: 'a', title: 'Alpha' }];
|
|
39
|
+
await openSearch({ results: flat });
|
|
40
|
+
expect(screen.getByRole('option', { name: /Alpha/ })).toBeInTheDocument();
|
|
41
|
+
expect(screen.queryByText('Pages')).not.toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
it('moves the active result across groups and points the input at it', async () => {
|
|
44
|
+
const { user, input } = await openSearch();
|
|
45
|
+
const options = screen.getAllByRole('option');
|
|
46
|
+
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
|
|
47
|
+
await user.keyboard('{ArrowDown}{ArrowDown}');
|
|
48
|
+
expect(input).toHaveAttribute('aria-activedescendant', options[2].id);
|
|
49
|
+
expect(options[2]).toHaveAttribute('aria-selected', 'true');
|
|
50
|
+
expect(options[0]).toHaveAttribute('aria-selected', 'false');
|
|
51
|
+
});
|
|
52
|
+
it('selects the active result on Enter and closes', async () => {
|
|
53
|
+
const onSelect = vi.fn();
|
|
54
|
+
const { user } = await openSearch({ onSelect });
|
|
55
|
+
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
|
56
|
+
expect(onSelect).toHaveBeenCalledWith(groups[1].items[0]);
|
|
57
|
+
await waitFor(() => expect(screen.queryByRole('combobox')).not.toBeInTheDocument());
|
|
58
|
+
});
|
|
59
|
+
it('shows result icons only when asked', async () => {
|
|
60
|
+
const { unmount } = await openSearch();
|
|
61
|
+
expect(screen.getByRole('option', { name: /Dashboard/ }).querySelector('svg')).toBeNull();
|
|
62
|
+
unmount();
|
|
63
|
+
await openSearch({ showIcons: true });
|
|
64
|
+
expect(screen.getByRole('option', { name: /Dashboard/ }).querySelector('svg')).not.toBeNull();
|
|
65
|
+
});
|
|
66
|
+
it('caps the results list at the given height', async () => {
|
|
67
|
+
await openSearch({ resultsMaxHeight: '60vh' });
|
|
68
|
+
const listbox = screen.getByRole('listbox');
|
|
69
|
+
expect(listbox.parentElement).toHaveStyle({ maxHeight: '60vh' });
|
|
70
|
+
});
|
|
71
|
+
it('has no axe violations with grouped results open', async () => {
|
|
72
|
+
await openSearch();
|
|
73
|
+
const dialog = screen.getByRole('dialog');
|
|
74
|
+
expect(await axe(dialog)).toHaveNoViolations();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar Collapse Tests
|
|
3
|
+
*
|
|
4
|
+
* DashboardLayout applies its `sidebarCollapsed` default only when no open
|
|
5
|
+
* state is persisted. A first session (nothing in localStorage yet) must
|
|
6
|
+
* still let the user collapse the sidebar — the default must not re-apply
|
|
7
|
+
* on every toggle and snap it back open.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
11
|
+
import { createRawSnippet, flushSync } from 'svelte';
|
|
12
|
+
import DashboardLayout from '../lib/components/layout/DashboardLayout.svelte';
|
|
13
|
+
import { sidebarStore } from '../lib/stores/sidebar.svelte.js';
|
|
14
|
+
let appCounter = 0;
|
|
15
|
+
function freshAppId() {
|
|
16
|
+
return `test-collapse-${++appCounter}`;
|
|
17
|
+
}
|
|
18
|
+
function renderLayout(appId) {
|
|
19
|
+
return render(DashboardLayout, {
|
|
20
|
+
props: {
|
|
21
|
+
navigation: [{ id: 'main', items: [{ id: 'home', name: 'Home', href: '/' }] }],
|
|
22
|
+
appId,
|
|
23
|
+
children: createRawSnippet(() => ({ render: () => '<p>content</p>' })),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
describe('sidebar collapse', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
localStorage.clear();
|
|
30
|
+
sidebarStore.setMobile(false);
|
|
31
|
+
});
|
|
32
|
+
it('collapses on a first session with no persisted state', async () => {
|
|
33
|
+
const appId = freshAppId();
|
|
34
|
+
renderLayout(appId);
|
|
35
|
+
expect(sidebarStore.isOpen).toBe(true);
|
|
36
|
+
await fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
|
|
37
|
+
flushSync();
|
|
38
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
39
|
+
expect(localStorage.getItem(`classic-theme-sidebar-${appId}-open`)).toBe('false');
|
|
40
|
+
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
it('keeps the collapse after a mobile round-trip in a first session', () => {
|
|
43
|
+
renderLayout(freshAppId());
|
|
44
|
+
sidebarStore.toggle();
|
|
45
|
+
flushSync();
|
|
46
|
+
sidebarStore.setMobile(true);
|
|
47
|
+
flushSync();
|
|
48
|
+
sidebarStore.setMobile(false);
|
|
49
|
+
flushSync();
|
|
50
|
+
expect(sidebarStore.isOpen).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it('marks state as persisted once the open state is written', () => {
|
|
53
|
+
sidebarStore.initialize(freshAppId());
|
|
54
|
+
expect(sidebarStore.hasPersistedState).toBe(false);
|
|
55
|
+
sidebarStore.close();
|
|
56
|
+
expect(sidebarStore.hasPersistedState).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* - Keyboard shortcut support (Cmd/Ctrl+K)
|
|
11
11
|
* - Full keyboard navigation in results
|
|
12
12
|
* - Custom result rendering via snippets
|
|
13
|
+
* - Grouped results with headings (pass `SearchResultGroup[]`)
|
|
14
|
+
* - Combobox semantics: the active result is announced as you arrow through
|
|
13
15
|
* - Loading and empty states
|
|
14
16
|
*
|
|
15
17
|
* @example Basic usage:
|
|
@@ -30,6 +32,7 @@
|
|
|
30
32
|
import type { Snippet } from 'svelte';
|
|
31
33
|
import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
|
|
32
34
|
import Spinner from './Spinner.svelte';
|
|
35
|
+
import Icon, { type IconName } from './Icon.svelte';
|
|
33
36
|
|
|
34
37
|
interface Props<T = SearchResultItem> {
|
|
35
38
|
/** Whether search is enabled/visible */
|
|
@@ -60,6 +63,10 @@
|
|
|
60
63
|
renderResult?: Snippet<[T, boolean]>;
|
|
61
64
|
/** Custom empty state renderer */
|
|
62
65
|
renderEmpty?: Snippet;
|
|
66
|
+
/** Show each result's `icon` (an Icon name) beside it (default: false) */
|
|
67
|
+
showIcons?: boolean;
|
|
68
|
+
/** Max height of the results list, any CSS length (default: '300px') */
|
|
69
|
+
resultsMaxHeight?: string;
|
|
63
70
|
/** Additional classes for trigger button */
|
|
64
71
|
class?: string;
|
|
65
72
|
}
|
|
@@ -79,9 +86,15 @@
|
|
|
79
86
|
size = 'md',
|
|
80
87
|
renderResult,
|
|
81
88
|
renderEmpty,
|
|
89
|
+
showIcons = false,
|
|
90
|
+
resultsMaxHeight = '300px',
|
|
82
91
|
class: className,
|
|
83
92
|
}: Props = $props();
|
|
84
93
|
|
|
94
|
+
const uid = $props.id();
|
|
95
|
+
const listboxId = `${uid}-results`;
|
|
96
|
+
const optionId = (index: number) => `${uid}-option-${index}`;
|
|
97
|
+
|
|
85
98
|
// State
|
|
86
99
|
let open = $state(false);
|
|
87
100
|
let query = $state('');
|
|
@@ -89,14 +102,36 @@
|
|
|
89
102
|
let inputRef: HTMLInputElement | undefined = $state();
|
|
90
103
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
91
104
|
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
function isGrouped(
|
|
106
|
+
value: SearchResultItem[] | SearchResultGroup[]
|
|
107
|
+
): value is SearchResultGroup[] {
|
|
108
|
+
return value.length > 0 && 'items' in value[0];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sections to render; ungrouped results are one untitled section. `offset`
|
|
112
|
+
// is each section's first index in the flat keyboard order.
|
|
113
|
+
const sections = $derived.by(() => {
|
|
94
114
|
if (!results || results.length === 0) return [];
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
115
|
+
const groups: { title?: string; items: SearchResultItem[] }[] = isGrouped(results)
|
|
116
|
+
? results
|
|
117
|
+
: [{ items: results as SearchResultItem[] }];
|
|
118
|
+
let offset = 0;
|
|
119
|
+
return groups
|
|
120
|
+
.filter((group) => group.items.length > 0)
|
|
121
|
+
.map((group) => {
|
|
122
|
+
const section = { title: group.title, items: group.items, offset };
|
|
123
|
+
offset += group.items.length;
|
|
124
|
+
return section;
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Flatten results for keyboard navigation
|
|
129
|
+
const flatResults = $derived(sections.flatMap((section) => section.items));
|
|
130
|
+
|
|
131
|
+
// Keep the active result in view while arrowing through a long list.
|
|
132
|
+
$effect(() => {
|
|
133
|
+
if (!open || flatResults.length === 0) return;
|
|
134
|
+
document.getElementById(optionId(selectedIndex))?.scrollIntoView?.({ block: 'nearest' });
|
|
100
135
|
});
|
|
101
136
|
|
|
102
137
|
// Keyboard shortcut handler
|
|
@@ -237,6 +272,7 @@
|
|
|
237
272
|
class="fixed left-[50%] top-[20%] z-50 w-full max-w-lg translate-x-[-50%] overflow-hidden rounded-lg border border-black bg-background shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:top-[15%]"
|
|
238
273
|
aria-describedby={undefined}
|
|
239
274
|
>
|
|
275
|
+
<DialogPrimitive.Title class="sr-only">Search</DialogPrimitive.Title>
|
|
240
276
|
<!-- Search Input -->
|
|
241
277
|
<div class="flex items-center border-b border-black bg-muted/30 px-3">
|
|
242
278
|
<svg
|
|
@@ -255,6 +291,12 @@
|
|
|
255
291
|
<input
|
|
256
292
|
bind:this={inputRef}
|
|
257
293
|
type="search"
|
|
294
|
+
role="combobox"
|
|
295
|
+
aria-label={placeholder}
|
|
296
|
+
aria-expanded={flatResults.length > 0}
|
|
297
|
+
aria-controls={listboxId}
|
|
298
|
+
aria-autocomplete="list"
|
|
299
|
+
aria-activedescendant={flatResults.length > 0 ? optionId(selectedIndex) : undefined}
|
|
258
300
|
{placeholder}
|
|
259
301
|
value={query}
|
|
260
302
|
oninput={(e) => handleInput(e.currentTarget.value)}
|
|
@@ -270,7 +312,7 @@
|
|
|
270
312
|
</div>
|
|
271
313
|
|
|
272
314
|
<!-- Results Area -->
|
|
273
|
-
<div class="
|
|
315
|
+
<div class="overflow-y-auto p-2" style:max-height={resultsMaxHeight}>
|
|
274
316
|
{#if loading && flatResults.length === 0}
|
|
275
317
|
<div class="flex items-center justify-center py-6">
|
|
276
318
|
<Spinner size="sm" />
|
|
@@ -286,40 +328,70 @@
|
|
|
286
328
|
{/if}
|
|
287
329
|
{:else if flatResults.length === 0}
|
|
288
330
|
<div class="py-6 text-center text-sm text-muted-foreground">Type to search...</div>
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
? 'bg-accent text-accent-foreground'
|
|
298
|
-
: 'hover:bg-accent-hover'
|
|
299
|
-
)}
|
|
300
|
-
role="option"
|
|
301
|
-
aria-selected={index === selectedIndex}
|
|
331
|
+
{/if}
|
|
332
|
+
|
|
333
|
+
<div id={listboxId} role="listbox" aria-label="Search results">
|
|
334
|
+
{#each sections as section, sectionIndex (section.offset)}
|
|
335
|
+
<div
|
|
336
|
+
role="group"
|
|
337
|
+
aria-labelledby={section.title ? `${uid}-group-${sectionIndex}` : undefined}
|
|
338
|
+
class={cn(sectionIndex > 0 && section.title && 'mt-2')}
|
|
302
339
|
>
|
|
303
|
-
{#if
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
{item.description}
|
|
311
|
-
</div>
|
|
312
|
-
{/if}
|
|
340
|
+
{#if section.title}
|
|
341
|
+
<div
|
|
342
|
+
id="{uid}-group-{sectionIndex}"
|
|
343
|
+
role="presentation"
|
|
344
|
+
class="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
345
|
+
>
|
|
346
|
+
{section.title}
|
|
313
347
|
</div>
|
|
314
|
-
{#if item.category}
|
|
315
|
-
<span class="shrink-0 text-xs text-muted-foreground">
|
|
316
|
-
{item.category}
|
|
317
|
-
</span>
|
|
318
|
-
{/if}
|
|
319
348
|
{/if}
|
|
320
|
-
|
|
349
|
+
{#each section.items as item, itemIndex (item.id)}
|
|
350
|
+
{@const index = section.offset + itemIndex}
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
id={optionId(index)}
|
|
354
|
+
tabindex="-1"
|
|
355
|
+
onclick={() => handleSelect(item)}
|
|
356
|
+
onmousemove={() => (selectedIndex = index)}
|
|
357
|
+
class={cn(
|
|
358
|
+
'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm',
|
|
359
|
+
index === selectedIndex
|
|
360
|
+
? 'bg-accent text-accent-foreground'
|
|
361
|
+
: 'hover:bg-accent-hover'
|
|
362
|
+
)}
|
|
363
|
+
role="option"
|
|
364
|
+
aria-selected={index === selectedIndex}
|
|
365
|
+
>
|
|
366
|
+
{#if renderResult}
|
|
367
|
+
{@render renderResult(item, index === selectedIndex)}
|
|
368
|
+
{:else}
|
|
369
|
+
{#if showIcons && item.icon}
|
|
370
|
+
<Icon
|
|
371
|
+
name={item.icon as IconName}
|
|
372
|
+
size={16}
|
|
373
|
+
class="mt-0.5 shrink-0 text-muted-foreground"
|
|
374
|
+
/>
|
|
375
|
+
{/if}
|
|
376
|
+
<div class="min-w-0 flex-1">
|
|
377
|
+
<div class="truncate font-medium">{item.title}</div>
|
|
378
|
+
{#if item.description}
|
|
379
|
+
<div class="truncate text-xs text-muted-foreground">
|
|
380
|
+
{item.description}
|
|
381
|
+
</div>
|
|
382
|
+
{/if}
|
|
383
|
+
</div>
|
|
384
|
+
{#if item.category}
|
|
385
|
+
<span class="shrink-0 text-xs text-muted-foreground">
|
|
386
|
+
{item.category}
|
|
387
|
+
</span>
|
|
388
|
+
{/if}
|
|
389
|
+
{/if}
|
|
390
|
+
</button>
|
|
391
|
+
{/each}
|
|
392
|
+
</div>
|
|
321
393
|
{/each}
|
|
322
|
-
|
|
394
|
+
</div>
|
|
323
395
|
</div>
|
|
324
396
|
|
|
325
397
|
<!-- Footer with keyboard hints -->
|
|
@@ -29,6 +29,10 @@ interface Props<T = SearchResultItem> {
|
|
|
29
29
|
renderResult?: Snippet<[T, boolean]>;
|
|
30
30
|
/** Custom empty state renderer */
|
|
31
31
|
renderEmpty?: Snippet;
|
|
32
|
+
/** Show each result's `icon` (an Icon name) beside it (default: false) */
|
|
33
|
+
showIcons?: boolean;
|
|
34
|
+
/** Max height of the results list, any CSS length (default: '300px') */
|
|
35
|
+
resultsMaxHeight?: string;
|
|
32
36
|
/** Additional classes for trigger button */
|
|
33
37
|
class?: string;
|
|
34
38
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Main content area with proper spacing
|
|
11
11
|
* - Integrated with sidebar store for state management
|
|
12
12
|
*/
|
|
13
|
-
import type
|
|
13
|
+
import { untrack, type Snippet } from 'svelte';
|
|
14
14
|
import type {
|
|
15
15
|
NavSection,
|
|
16
16
|
NavItem,
|
|
@@ -201,13 +201,18 @@
|
|
|
201
201
|
$effect(() => {
|
|
202
202
|
sidebarStore.initialize(appId);
|
|
203
203
|
|
|
204
|
-
// Only apply prop-based initial state if no persisted state exists
|
|
204
|
+
// Only apply prop-based initial state if no persisted state exists.
|
|
205
|
+
// Untracked: open()/close() read `isOpen`, and tracking it would re-run
|
|
206
|
+
// this effect on every toggle and snap the sidebar back to the default.
|
|
205
207
|
if (!sidebarStore.hasPersistedState && !isMobile) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
const collapsed = sidebarCollapsed;
|
|
209
|
+
untrack(() => {
|
|
210
|
+
if (collapsed) {
|
|
211
|
+
sidebarStore.close();
|
|
212
|
+
} else {
|
|
213
|
+
sidebarStore.open();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
211
216
|
}
|
|
212
217
|
});
|
|
213
218
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - Main content area with proper spacing
|
|
10
10
|
* - Integrated with sidebar store for state management
|
|
11
11
|
*/
|
|
12
|
-
import type
|
|
12
|
+
import { type Snippet } from 'svelte';
|
|
13
13
|
import type { NavSection, NavItem, User, QuickLink, BackLink, HeaderSearchConfig } from '../../types/layout.js';
|
|
14
14
|
type Breakpoint = 'sm' | 'md' | 'lg';
|
|
15
15
|
interface Props {
|
|
@@ -18,7 +18,8 @@ declare class SidebarStore {
|
|
|
18
18
|
*/
|
|
19
19
|
get initialized(): boolean;
|
|
20
20
|
/**
|
|
21
|
-
* Whether
|
|
21
|
+
* Whether a sidebar open state exists in localStorage (loaded at
|
|
22
|
+
* initialize, or written since)
|
|
22
23
|
*/
|
|
23
24
|
get hasPersistedState(): boolean;
|
|
24
25
|
/**
|
|
@@ -45,7 +45,8 @@ class SidebarStore {
|
|
|
45
45
|
return __classPrivateFieldGet(this, _SidebarStore_initialized, "f");
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
* Whether
|
|
48
|
+
* Whether a sidebar open state exists in localStorage (loaded at
|
|
49
|
+
* initialize, or written since)
|
|
49
50
|
*/
|
|
50
51
|
get hasPersistedState() {
|
|
51
52
|
return __classPrivateFieldGet(this, _SidebarStore_loadedFromStorage, "f");
|
|
@@ -340,6 +341,9 @@ _SidebarStore_initialized = new WeakMap(), _SidebarStore_loadedFromStorage = new
|
|
|
340
341
|
return;
|
|
341
342
|
try {
|
|
342
343
|
localStorage.setItem(__classPrivateFieldGet(this, _SidebarStore_instances, "m", _SidebarStore_getStorageKey).call(this, 'open'), String(this.isOpen));
|
|
344
|
+
// The key now exists: a caller that applies defaults only when nothing
|
|
345
|
+
// is persisted must stop doing so, or it undoes the user's toggle.
|
|
346
|
+
__classPrivateFieldSet(this, _SidebarStore_loadedFromStorage, true, "f");
|
|
343
347
|
}
|
|
344
348
|
catch {
|
|
345
349
|
// localStorage not available
|
|
@@ -63,6 +63,10 @@ export interface HeaderSearchConfig<T = SearchResultItem> {
|
|
|
63
63
|
renderResult?: Snippet<[T, boolean]>;
|
|
64
64
|
/** Custom empty state renderer */
|
|
65
65
|
renderEmpty?: Snippet;
|
|
66
|
+
/** Show each result's `icon` (an Icon name) beside it (default: false) */
|
|
67
|
+
showIcons?: boolean;
|
|
68
|
+
/** Max height of the results list, any CSS length (default: '300px') */
|
|
69
|
+
resultsMaxHeight?: string;
|
|
66
70
|
}
|
|
67
71
|
/**
|
|
68
72
|
* Navigation item for sidebar and header navigation
|