feat: a11y and ux tweaks (#2712)
Co-authored-by: Natan Yagudayev <natanyagudayev@gmail.com>
This commit is contained in:
parent
69983f9584
commit
d5c9c527d7
19 changed files with 406 additions and 55 deletions
|
|
@ -38,7 +38,7 @@ export default function WTransformItems(): JSX.Element {
|
|||
return items.map((item: any) => ({
|
||||
objectID: item.objectID,
|
||||
content: item.content ?? '',
|
||||
url: new URL(item.domain + item.path).toString(),
|
||||
url: item.domain + item.path,
|
||||
hierarchy: {
|
||||
lvl0: item.breadcrumb.join(' > ') ?? '',
|
||||
lvl1: item.h1 ?? '',
|
||||
|
|
@ -48,7 +48,7 @@ export default function WTransformItems(): JSX.Element {
|
|||
lvl5: null,
|
||||
lvl6: null,
|
||||
},
|
||||
url_without_anchor: new URL(item.domain + item.path).toString(),
|
||||
url_without_anchor: item.domain + item.path,
|
||||
type: 'content' as const,
|
||||
anchor: null,
|
||||
_highlightResult: item._highlightResult,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"rollup": "4.31.0",
|
||||
"rollup-plugin-dts": "6.1.1",
|
||||
"rollup-plugin-filesize": "10.0.0",
|
||||
"shipjs": "0.27.0",
|
||||
"start-server-and-test": "2.0.10",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
|
||||
/* searchbox */
|
||||
--docsearch-searchbox-height: 56px;
|
||||
--docsearch-searchbox-initial-height: 56px;
|
||||
--docsearch-searchbox-background: #ffffffa6;
|
||||
--docsearch-searchbox-focus-background: #ffffffa6;
|
||||
--docsearch-actions-width: 99px;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
cursor: pointer;
|
||||
display: flex;
|
||||
all: unset;
|
||||
width: 320px;
|
||||
height: 36px;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
|
|
|
|||
|
|
@ -68,9 +68,14 @@
|
|||
border-bottom: 1px solid var(--docsearch-subtle-color);
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: flex;
|
||||
height: var(--docsearch-searchbox-height);
|
||||
min-height: var(--docsearch-searchbox-initial-height);
|
||||
height: var(
|
||||
--docsearch-searchbox-height,
|
||||
var(--docsearch-searchbox-initial-height)
|
||||
);
|
||||
margin: 0;
|
||||
padding: var(--docsearch-spacing) 16px;
|
||||
padding-block: var(--docsearch-spacing);
|
||||
padding-inline: 16px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -86,8 +91,12 @@
|
|||
font-weight: 300;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
padding: 0 0 0 8px;
|
||||
padding-block-start: 4px;
|
||||
padding-inline-start: 8px;
|
||||
width: 80%;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
overflow-y: hidden; /* js toggles to auto when exceeding max */
|
||||
}
|
||||
|
||||
.DocSearch-Input::placeholder {
|
||||
|
|
@ -113,7 +122,7 @@
|
|||
}
|
||||
|
||||
.DocSearch-Divider {
|
||||
height: 56px;
|
||||
height: 16px;
|
||||
border-left: 1px solid var(--docsearch-subtle-color);
|
||||
}
|
||||
|
||||
|
|
@ -232,8 +241,8 @@
|
|||
|
||||
.DocSearch-Dropdown {
|
||||
height: calc(
|
||||
var(--docsearch-modal-height) - var(--docsearch-searchbox-height) -
|
||||
var(--docsearch-spacing) - var(--docsearch-footer-height)
|
||||
var(--docsearch-modal-height) - var(--docsearch-spacing) -
|
||||
var(--docsearch-footer-height)
|
||||
);
|
||||
min-height: var(--docsearch-spacing);
|
||||
overflow-y: auto; /* firefox */
|
||||
|
|
@ -850,6 +859,7 @@ assistive tech users */
|
|||
|
||||
.DocSearch-AskAiScreen-Query {
|
||||
font-size: 1.5em;
|
||||
line-break: anywhere;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1337,10 +1347,7 @@ assistive tech users */
|
|||
}
|
||||
|
||||
.DocSearch-Dropdown {
|
||||
max-height: calc(
|
||||
var(--docsearch-vh, 1vh) * 100 - var(--docsearch-searchbox-height) -
|
||||
var(--docsearch-spacing) - var(--docsearch-footer-height)
|
||||
);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.DocSearch-Commands {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"@docsearch/react": "4.0.0-beta.6",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"nodemon": "^3.1.9",
|
||||
"preact": "^10.0.0"
|
||||
"preact": "^10.0.0",
|
||||
"rollup-plugin-dts": "^6.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import replace from '@rollup/plugin-replace';
|
||||
import { dts } from 'rollup-plugin-dts';
|
||||
|
||||
import { plugins, typesConfig } from '../../rollup.base.config';
|
||||
import { plugins } from '../../rollup.base.config';
|
||||
import { getBundleBanner } from '../../scripts/getBundleBanner';
|
||||
|
||||
import pkg from './package.json';
|
||||
|
|
@ -14,7 +15,6 @@ export default [
|
|||
format: 'es',
|
||||
sourcemap: true,
|
||||
banner: getBundleBanner(pkg),
|
||||
plugins: [...plugins],
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
|
|
@ -44,5 +44,14 @@ export default [
|
|||
}),
|
||||
],
|
||||
},
|
||||
typesConfig,
|
||||
{
|
||||
input: 'dist/esm/types/index.d.ts',
|
||||
output: [{ file: 'dist/esm/index.d.ts', format: 'es' }],
|
||||
external: (id) => /^(react|react-dom|@types\/react|@ai-sdk\/react)/.test(id),
|
||||
plugins: [
|
||||
dts({
|
||||
respectExternal: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export function docsearch(props: DocSearchProps): void {
|
|||
{...props}
|
||||
transformSearchClient={(searchClient) => {
|
||||
searchClient.addAlgoliaAgent('docsearch.js', version);
|
||||
|
||||
return props.transformSearchClient ? props.transformSearchClient(searchClient) : searchClient;
|
||||
}}
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.2.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"rollup-plugin-dts": "^6.2.1",
|
||||
"vitest": "3.0.2",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import { dts } from 'rollup-plugin-dts';
|
||||
|
||||
import { plugins, typesConfig } from '../../rollup.base.config';
|
||||
import { plugins } from '../../rollup.base.config';
|
||||
import { getBundleBanner } from '../../scripts/getBundleBanner';
|
||||
|
||||
import pkg from './package.json';
|
||||
|
|
@ -34,5 +35,9 @@ export default [
|
|||
}),
|
||||
],
|
||||
},
|
||||
typesConfig,
|
||||
{
|
||||
input: 'dist/esm/types/index.d.ts',
|
||||
output: [{ file: 'dist/esm/index.d.ts', format: 'es' }],
|
||||
plugins: [dts()],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,32 +52,90 @@ export type DocSearchAskAi = {
|
|||
};
|
||||
|
||||
export interface DocSearchProps {
|
||||
/**
|
||||
* Algolia application id used by the search client.
|
||||
*/
|
||||
appId: string;
|
||||
/**
|
||||
* Public api key with search permissions for the index.
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* Name of the algolia index to query.
|
||||
*/
|
||||
indexName: string;
|
||||
/**
|
||||
* Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object.
|
||||
*/
|
||||
askAi?: DocSearchAskAi | string;
|
||||
/**
|
||||
* Theme overrides applied to the modal and related components.
|
||||
*/
|
||||
theme?: DocSearchTheme;
|
||||
/**
|
||||
* Placeholder text for the search input.
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Additional algolia search parameters to merge into each query.
|
||||
*/
|
||||
searchParameters?: SearchParamsObject;
|
||||
/**
|
||||
* Maximum number of hits to display per source/group.
|
||||
*/
|
||||
maxResultsPerGroup?: number;
|
||||
/**
|
||||
* Hook to post-process hits before rendering.
|
||||
*/
|
||||
transformItems?: (items: DocSearchHit[]) => DocSearchHit[];
|
||||
/**
|
||||
* Custom component to render an individual hit.
|
||||
*/
|
||||
hitComponent?: (props: { hit: InternalDocSearchHit | StoredDocSearchHit; children: React.ReactNode }) => JSX.Element;
|
||||
/**
|
||||
* Custom component rendered at the bottom of the results panel.
|
||||
*/
|
||||
resultsFooterComponent?: (props: { state: AutocompleteState<InternalDocSearchHit> }) => JSX.Element | null;
|
||||
/**
|
||||
* Hook to wrap or modify the algolia search client.
|
||||
*/
|
||||
transformSearchClient?: (searchClient: DocSearchTransformClient) => DocSearchTransformClient;
|
||||
/**
|
||||
* Disable storage and usage of recent and favorite searches.
|
||||
*/
|
||||
disableUserPersonalization?: boolean;
|
||||
/**
|
||||
* Query string to prefill when opening the modal.
|
||||
*/
|
||||
initialQuery?: string;
|
||||
/**
|
||||
* Custom navigator for controlling link navigation.
|
||||
*/
|
||||
navigator?: AutocompleteOptions<InternalDocSearchHit>['navigator'];
|
||||
/**
|
||||
* Localized strings for the button and modal ui.
|
||||
*/
|
||||
translations?: DocSearchTranslations;
|
||||
/**
|
||||
* Builds a url to report missing results for a given query.
|
||||
*/
|
||||
getMissingResultsUrl?: ({ query }: { query: string }) => string;
|
||||
/**
|
||||
* Insights client integration options to send analytics events.
|
||||
*/
|
||||
insights?: AutocompleteOptions<InternalDocSearchHit>['insights'];
|
||||
/**
|
||||
* Limit of how many recent searches that should be saved/displayed.
|
||||
* The container element where the modal should be portaled to. Defaults to document.body.
|
||||
*/
|
||||
portalContainer?: DocumentFragment | Element;
|
||||
/**
|
||||
* Limit of how many recent searches should be saved/displayed..
|
||||
*
|
||||
* @default 7
|
||||
*/
|
||||
recentSearchesLimit?: number;
|
||||
/**
|
||||
* Limit of how many recent searches that should be saved/displayed when there are favorited searches.
|
||||
* Limit of how many recent searches should be saved/displayed when there are favorited searches..
|
||||
*
|
||||
* @default 4
|
||||
*/
|
||||
|
|
@ -159,7 +217,7 @@ export function DocSearch({ ...props }: DocSearchProps): JSX.Element {
|
|||
onAskAiToggle={onAskAiToggle}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
document.body,
|
||||
props.portalContainer ?? document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useTouchEvents } from './useTouchEvents';
|
|||
import { useTrapFocus } from './useTrapFocus';
|
||||
import { groupBy, identity, noop, removeHighlightTags, isModifierEvent, scrollTo as scrollToUtils } from './utils';
|
||||
import { buildDummyAskAiHit } from './utils/ai';
|
||||
import { manageLocalStorageQuota } from './utils/storage';
|
||||
|
||||
export type ModalTranslations = Partial<{
|
||||
searchBox: SearchBoxTranslations;
|
||||
|
|
@ -621,6 +622,11 @@ export function DocSearchModal({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Proactively manage localStorage quota to prevent crashes
|
||||
React.useEffect(() => {
|
||||
manageLocalStorageQuota();
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Calculate the scrollbar width to compensate for removed scrollbar
|
||||
const scrollBarWidth = window.innerWidth - document.body.clientWidth;
|
||||
|
|
|
|||
|
|
@ -305,6 +305,40 @@ describe('api', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('portalContainer', () => {
|
||||
it('renders the modal inside document.body by default', async () => {
|
||||
render(<DocSearch />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByText('Search'));
|
||||
});
|
||||
|
||||
const portal = document.querySelector('.DocSearch-Container');
|
||||
|
||||
expect(portal).toBeInTheDocument();
|
||||
expect(portal?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('renders the modal inside the provided portal container', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
render(<DocSearch portalContainer={container} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByText('Search'));
|
||||
});
|
||||
|
||||
const portal = container.querySelector('.DocSearch-Container');
|
||||
|
||||
expect(portal).toBeInTheDocument();
|
||||
expect(portal?.parentElement).toBe(container);
|
||||
|
||||
// clean up manually created container
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme', () => {
|
||||
const html = document.documentElement;
|
||||
it('light theme', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { extractLinksFromText } from '../utils/ai';
|
||||
import { createObjectStorage, isLocalStorageSupported } from '../utils/storage';
|
||||
import {
|
||||
createObjectStorage,
|
||||
createStorage,
|
||||
isLocalStorageSupported,
|
||||
getLocalStorageSize,
|
||||
manageLocalStorageQuota,
|
||||
} from '../utils/storage';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('extractLinksFromText', () => {
|
||||
|
|
@ -72,4 +78,86 @@ https://docsearch.algolia.com/configuration?version=beta
|
|||
expect(isLocalStorageSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage quota handling', () => {
|
||||
const testKey = '__TEST_QUOTA_STORAGE__';
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up any test data
|
||||
localStorage.removeItem(testKey);
|
||||
// Clean up any DocSearch keys that might exist
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (key.includes('__DOCSEARCH_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('getLocalStorageSize returns a number', () => {
|
||||
const size = getLocalStorageSize();
|
||||
expect(typeof size).toBe('number');
|
||||
expect(size).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('createStorage handles quota exceeded errors gracefully', () => {
|
||||
const storage = createStorage<{ data: string }>(testKey);
|
||||
|
||||
// Create a large dataset that might cause quota issues
|
||||
const largeArray = Array.from({ length: 1000 }, (_, i) => ({ data: `test-data-${i}`.repeat(100) }));
|
||||
|
||||
// This should not throw an error even if quota is exceeded
|
||||
expect(() => {
|
||||
storage.setItem(largeArray);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should be able to retrieve data (might be reduced if quota was exceeded)
|
||||
const retrieved = storage.getItem();
|
||||
expect(Array.isArray(retrieved)).toBe(true);
|
||||
});
|
||||
|
||||
it('createObjectStorage handles quota exceeded errors gracefully', () => {
|
||||
const storage = createObjectStorage<{ data: string }>(testKey);
|
||||
|
||||
// Create a large object that might cause quota issues
|
||||
const largeObject = { data: 'x'.repeat(1000000) }; // 1MB string
|
||||
|
||||
// This should not throw an error even if quota is exceeded
|
||||
expect(() => {
|
||||
storage.setItem(largeObject);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('manageLocalStorageQuota runs without errors', () => {
|
||||
// Add some DocSearch data to localStorage
|
||||
localStorage.setItem('__DOCSEARCH_TEST_1__', JSON.stringify({ test: 'data1' }));
|
||||
localStorage.setItem('__DOCSEARCH_TEST_2__', JSON.stringify({ test: 'data2' }));
|
||||
|
||||
// This should not throw an error
|
||||
expect(() => {
|
||||
manageLocalStorageQuota();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('storage functions work correctly with normal data', () => {
|
||||
const arrayStorage = createStorage<{ id: number; name: string }>(testKey + '_array');
|
||||
const objectStorage = createObjectStorage<{ count: number }>(testKey + '_object');
|
||||
|
||||
// Test array storage
|
||||
const testArray = [
|
||||
{ id: 1, name: 'test1' },
|
||||
{ id: 2, name: 'test2' },
|
||||
];
|
||||
arrayStorage.setItem(testArray);
|
||||
expect(arrayStorage.getItem()).toEqual(testArray);
|
||||
|
||||
// Test object storage
|
||||
const testObject = { count: 42 };
|
||||
objectStorage.setItem(testObject);
|
||||
expect(objectStorage.getItem()).toEqual(testObject);
|
||||
|
||||
// Test null handling
|
||||
objectStorage.setItem(null);
|
||||
expect(objectStorage.getItem()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,110 @@
|
|||
/**
|
||||
* Estimates the size of localStorage usage in bytes.
|
||||
*/
|
||||
export function getLocalStorageSize(): number {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const key in window.localStorage) {
|
||||
if (window.localStorage.hasOwnProperty(key)) {
|
||||
total += window.localStorage[key].length + key.length;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to free up localStorage space by removing DocSearch-related items
|
||||
* starting with the oldest/largest ones.
|
||||
*/
|
||||
function cleanupDocSearchStorage(): void {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docSearchKeys: Array<{ key: string; size: number }> = [];
|
||||
|
||||
// Find all DocSearch-related keys and their sizes
|
||||
for (const key in window.localStorage) {
|
||||
if (key.includes('__DOCSEARCH_')) {
|
||||
const value = window.localStorage[key];
|
||||
docSearchKeys.push({ key, size: value.length + key.length });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by size (largest first) to remove the most impactful items
|
||||
docSearchKeys.sort((a, b) => b.size - a.size);
|
||||
|
||||
// Remove up to half of the DocSearch items, starting with the largest
|
||||
const itemsToRemove = Math.ceil(docSearchKeys.length / 2);
|
||||
for (let i = 0; i < itemsToRemove && i < docSearchKeys.length; i++) {
|
||||
try {
|
||||
window.localStorage.removeItem(docSearchKeys[i].key);
|
||||
} catch {
|
||||
// Silently ignore cleanup errors to prevent crashes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely sets an item in localStorage with quota exceeded error handling.
|
||||
*
|
||||
* @param key - The localStorage key.
|
||||
* @param value - The value to store (will be JSON.stringified).
|
||||
*/
|
||||
function safeSetLocalStorageItem(key: string, value: any): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
// Handle quota exceeded error by clearing old data and retrying
|
||||
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
try {
|
||||
// First, try comprehensive cleanup of DocSearch storage
|
||||
cleanupDocSearchStorage();
|
||||
// Retry with original data
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// If still failing, silently fail to prevent crashes
|
||||
}
|
||||
} else {
|
||||
// For other localStorage errors, silently fail to prevent crashes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively manages localStorage quota by cleaning up when usage is high
|
||||
* Should be called periodically to prevent quota exceeded errors.
|
||||
*/
|
||||
export function manageLocalStorageQuota(): void {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSize = getLocalStorageSize();
|
||||
// Typical localStorage limit is 5-10MB, start cleanup at 4MB to be safe
|
||||
const CLEANUP_THRESHOLD = 4 * 1024 * 1024; // 4MB
|
||||
|
||||
if (currentSize > CLEANUP_THRESHOLD) {
|
||||
cleanupDocSearchStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if local storage is available and usable.
|
||||
*/
|
||||
export function isLocalStorageSupported(): boolean {
|
||||
// guard against ssr and browsers where localstorage is disabled
|
||||
if (typeof window === 'undefined' || !('localStorage' in window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = '__TEST_KEY__';
|
||||
try {
|
||||
localStorage.setItem(key, '');
|
||||
localStorage.removeItem(key);
|
||||
window.localStorage.setItem(key, '');
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -34,11 +133,19 @@ export function createStorage<TItem>(key: string) {
|
|||
|
||||
return {
|
||||
setItem(item: TItem[]): void {
|
||||
return window.localStorage.setItem(key, JSON.stringify(item));
|
||||
safeSetLocalStorageItem(key, item);
|
||||
},
|
||||
getItem(): TItem[] {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : [];
|
||||
if (item === null) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(item);
|
||||
return Array.isArray(parsed) ? (parsed as TItem[]) : [];
|
||||
} catch {
|
||||
// clear corrupted data and return empty list
|
||||
window.localStorage.removeItem(key);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -69,7 +176,7 @@ export function createObjectStorage<TItem>(key: string) {
|
|||
if (item === null) {
|
||||
window.localStorage.removeItem(key);
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(item));
|
||||
safeSetLocalStorageItem(key, item);
|
||||
}
|
||||
},
|
||||
getItem(): TItem | null {
|
||||
|
|
|
|||
|
|
@ -536,6 +536,58 @@ docsearch({
|
|||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## `portalContainer` (React-only)
|
||||
|
||||
> `type: Element | DocumentFragment` | `default: document.body` | **optional**
|
||||
|
||||
The element where the DocSearch modal will be portaled. Use this when you need the overlay to render in a custom DOM node—for example when working inside a shadow root, a specific layout container, or a modal manager. When omitted, the modal portals to `document.body`.
|
||||
|
||||
:::warning
|
||||
|
||||
This prop only exists in `@docsearch/react`. If you are using **`@docsearch/js`**, use the [`container`](#container) option instead—the value you pass there is both the **mount point** of the search button *and* the portal target for the modal.
|
||||
|
||||
:::
|
||||
|
||||
<Tabs
|
||||
groupId="language"
|
||||
defaultValue="react"
|
||||
values={[{ label: 'React', value: 'react' }, { label: 'JavaScript', value: 'js' }]}
|
||||
>
|
||||
<TabItem value="react">
|
||||
|
||||
```jsx
|
||||
// assume you have a dedicated modal root in your html
|
||||
<div id="modal-root" />
|
||||
|
||||
const portalEl = document.getElementById('modal-root');
|
||||
|
||||
<DocSearch
|
||||
appId="YOUR_APP_ID"
|
||||
apiKey="YOUR_SEARCH_API_KEY"
|
||||
indexName="YOUR_INDEX_NAME"
|
||||
// render the modal inside #modal-root instead of document.body
|
||||
portalContainer={portalEl}
|
||||
/>;
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="js">
|
||||
|
||||
```js
|
||||
docsearch({
|
||||
// the element that will **contain the button** and **host the modal portal**
|
||||
container: '#modal-root',
|
||||
appId: 'YOUR_APP_ID',
|
||||
apiKey: 'YOUR_SEARCH_API_KEY',
|
||||
indexName: 'YOUR_INDEX_NAME',
|
||||
});
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
[1]: https://www.algolia.com/doc/ui-libraries/autocomplete/introduction/what-is-autocomplete/
|
||||
[2]: https://github.com/algolia/docsearch/
|
||||
[3]: https://github.com/algolia/docsearch/tree/master
|
||||
|
|
|
|||
|
|
@ -720,15 +720,6 @@ html[data-theme='dark'] .shimmer-effect {
|
|||
@apply bg-neutral-100 dark:bg-zinc-800 py-16 rounded-2xl ring-1 ring-neutral-200 dark:ring-neutral-700 ring-offset-3 dark:ring-offset-neutral-900;
|
||||
}
|
||||
|
||||
/* Make the DocSearch search bar button prominent - desktop only */
|
||||
@media (min-width: 769px) {
|
||||
.DocSearch-Button {
|
||||
position: relative;
|
||||
width: 400px !important;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-Button:focus,
|
||||
.DocSearch-Button:hover {
|
||||
box-shadow:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import json from '@rollup/plugin-json';
|
|||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import { dts } from 'rollup-plugin-dts';
|
||||
import filesize from 'rollup-plugin-filesize';
|
||||
|
||||
export const plugins = [
|
||||
|
|
@ -28,9 +27,3 @@ export const plugins = [
|
|||
showGzippedSize: true,
|
||||
}),
|
||||
];
|
||||
|
||||
export const typesConfig = {
|
||||
input: 'dist/esm/types/index.d.ts',
|
||||
output: [{ file: 'dist/esm/index.d.ts', format: 'es' }],
|
||||
plugins: [dts()],
|
||||
};
|
||||
|
|
|
|||
19
yarn.lock
19
yarn.lock
|
|
@ -417,7 +417,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.24.2, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0, @babel/code-frame@npm:^7.26.2":
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0, @babel/code-frame@npm:^7.26.2":
|
||||
version: 7.26.2
|
||||
resolution: "@babel/code-frame@npm:7.26.2"
|
||||
dependencies:
|
||||
|
|
@ -2343,6 +2343,7 @@ __metadata:
|
|||
"@rollup/plugin-replace": "npm:6.0.2"
|
||||
nodemon: "npm:^3.1.9"
|
||||
preact: "npm:^10.0.0"
|
||||
rollup-plugin-dts: "npm:^6.2.1"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
|
@ -2388,7 +2389,6 @@ __metadata:
|
|||
react: "npm:^19.0.0"
|
||||
react-dom: "npm:^19.0.0"
|
||||
rollup: "npm:4.31.0"
|
||||
rollup-plugin-dts: "npm:6.1.1"
|
||||
rollup-plugin-filesize: "npm:10.0.0"
|
||||
shipjs: "npm:0.27.0"
|
||||
start-server-and-test: "npm:2.0.10"
|
||||
|
|
@ -2431,6 +2431,7 @@ __metadata:
|
|||
algoliasearch: "npm:^5.28.0"
|
||||
marked: "npm:^15.0.12"
|
||||
nodemon: "npm:^3.1.0"
|
||||
rollup-plugin-dts: "npm:^6.2.1"
|
||||
vitest: "npm:3.0.2"
|
||||
zod: "npm:^3.25.67"
|
||||
peerDependencies:
|
||||
|
|
@ -16727,7 +16728,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-string@npm:^0.30.10, magic-string@npm:^0.30.17, magic-string@npm:^0.30.3":
|
||||
"magic-string@npm:^0.30.17, magic-string@npm:^0.30.3":
|
||||
version: 0.30.17
|
||||
resolution: "magic-string@npm:0.30.17"
|
||||
dependencies:
|
||||
|
|
@ -22564,19 +22565,19 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rollup-plugin-dts@npm:6.1.1":
|
||||
version: 6.1.1
|
||||
resolution: "rollup-plugin-dts@npm:6.1.1"
|
||||
"rollup-plugin-dts@npm:^6.2.1":
|
||||
version: 6.2.1
|
||||
resolution: "rollup-plugin-dts@npm:6.2.1"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:^7.24.2"
|
||||
magic-string: "npm:^0.30.10"
|
||||
"@babel/code-frame": "npm:^7.26.2"
|
||||
magic-string: "npm:^0.30.17"
|
||||
peerDependencies:
|
||||
rollup: ^3.29.4 || ^4
|
||||
typescript: ^4.5 || ^5.0
|
||||
dependenciesMeta:
|
||||
"@babel/code-frame":
|
||||
optional: true
|
||||
checksum: 10c0/2b042198ff00fb10c9c70087bbac9013f748dc34be0dbfcca82c6353884ead1467b4a8a37bafe9a8b9356479d43715c7cbc591eeb8e4112c583452431c1cb0a0
|
||||
checksum: 10c0/f21c8726470851a40e6ca68ae580261cee8bc6275775291b9c0fdf93b868ed54f12b11c8c0dddce2c14f5691d6032b6647d094835ab9b6789226efa60e1aa71e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue