1
0
Fork 0

feat: a11y and ux tweaks (#2712)

Co-authored-by: Natan Yagudayev <natanyagudayev@gmail.com>
This commit is contained in:
Dylan Tientcheu 2025-08-14 20:04:30 +02:00 committed by GitHub
parent 69983f9584
commit d5c9c527d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 406 additions and 55 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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;

View file

@ -7,7 +7,6 @@
cursor: pointer;
display: flex;
all: unset;
width: 320px;
height: 36px;
justify-content: space-between;
padding: 0 8px;

View file

@ -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 {

View file

@ -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"
}
}

View file

@ -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,
}),
],
},
];

View file

@ -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;
}}
/>,

View file

@ -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"
},

View file

@ -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()],
},
];

View file

@ -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,
)}
</>
);

View file

@ -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;

View file

@ -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', () => {

View file

@ -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();
});
});
});

View file

@ -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 {

View file

@ -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

View file

@ -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:

View file

@ -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()],
};

View file

@ -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