1
0
Fork 0

feat(v5): UI updates (#2896)

* feat(v5): UI updates

* fix: css file size

* fix: e2e tests

* fix: e2e tests

* fix: e2e tests

* chore: add theme toggle to react demo example

* Update sources panel display, update dark theme

* fix: lint

* fix(askai): address ui review feedback

* fix: pin icon positioning

* fix(askai): improve a11y and dark-mode shimmer for thinking and error states

- add role=alert/status and aria-hidden on error/thinking UI
- support dark-mode shimmer gradients via CSS variables
- respect prefers-reduced-motion for shimmer
- handle null date in useRelativeFormattedDate with fallback translation
This commit is contained in:
Paul Jankowski 2026-06-11 12:51:22 -04:00 committed by GitHub
parent 34ea03a2eb
commit 0da291bb95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1418 additions and 465 deletions

View file

@ -180,6 +180,7 @@
"version": "4.6.0",
"dependencies": {
"@algolia/autocomplete-core": "1.19.2",
"@base-ui/react": "^1.5.0",
"@docsearch/core": "4.6.0",
"@docsearch/css": "4.6.0",
},
@ -554,6 +555,10 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@base-ui/react": ["@base-ui/react@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.9", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A=="],
"@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="],
"@cacheable/memory": ["@cacheable/memory@2.0.7", "", { "dependencies": { "@cacheable/utils": "^2.3.3", "@keyv/bigmap": "^1.3.0", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A=="],
"@cacheable/utils": ["@cacheable/utils@2.3.3", "", { "dependencies": { "hashery": "^1.3.0", "keyv": "^5.5.5" } }, "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A=="],
@ -830,13 +835,13 @@
"@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="],
@ -3282,6 +3287,8 @@
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
"resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
@ -3854,6 +3861,10 @@
"@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@cacheable/memory/keyv": ["keyv@5.5.5", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ=="],
"@cacheable/utils/keyv": ["keyv@5.5.5", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ=="],
@ -3918,6 +3929,8 @@
"@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@stylistic/stylelint-plugin/@csstools/media-query-list-parser": ["@csstools/media-query-list-parser@3.0.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.1", "@csstools/css-tokenizer": "^3.0.1" } }, "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw=="],
"@stylistic/stylelint-plugin/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@ -4564,6 +4577,8 @@
"@manypkg/get-packages/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@svgr/core/cosmiconfig/import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"@svgr/core/cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
@ -5000,6 +5015,10 @@
"@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@svgr/core/cosmiconfig/import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"@svgr/plugin-svgo/cosmiconfig/import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],

View file

@ -2,7 +2,7 @@
"files": [
{
"path": "packages/docsearch-css/dist/style.css",
"maxSize": "6.15 kB"
"maxSize": "7.10 kB"
},
{
"path": "packages/docsearch-react/dist/umd/index.js",

View file

@ -101,7 +101,7 @@ test.describe('Search', () => {
const initialURL = page.url();
await docSearch.typeQueryMatching();
await page.locator('#docsearch-hits_docsearch_0-item-1 > a').click({ force: true });
await page.locator('#docsearch-hits_docsearch-item-0 > a').click({ force: true });
await expect(page).not.toHaveURL(initialURL);
});
@ -121,7 +121,7 @@ test.describe('Recent and Favorites', () => {
await docSearch.goto();
await docSearch.openModal();
await docSearch.typeQueryMatching();
await page.locator('#docsearch-hits_docsearch_0-item-1 > a').click({ force: true });
await page.locator('#docsearch-hits_docsearch-item-0 > a').click({ force: true });
await page.waitForTimeout(1000);
await docSearch.openModal();
await expect(page.getByText('Recent')).toBeVisible();
@ -137,19 +137,16 @@ test.describe('Recent and Favorites', () => {
await expect(docSearch.hits).not.toBeVisible();
});
test('Recent search can be favorited', async ({ page }) => {
await page.locator('#docsearch-recentSearches-item-0').locator('[title="Save this search"]').click();
await expect(page.getByText('Favorite')).toBeVisible();
test('Recent search can be pinned', async ({ page }) => {
await page.locator('#docsearch-recentSearches-item-0').locator('[title="Pin this search"]').click();
await expect(page.getByText('Pinned')).toBeVisible();
await expect(page.locator('#docsearch-favoriteSearches-item-0')).toBeVisible();
});
test('Favorite can be deleted', async ({ docSearch, page }) => {
await page.locator('#docsearch-recentSearches-item-0').locator('[title="Save this search"]').click();
await expect(page.getByText('Favorite')).toBeVisible();
await page
.locator('#docsearch-favoriteSearches-item-0')
.locator('[title="Remove this search from favorites"]')
.click();
test('Pinned can be deleted', async ({ docSearch, page }) => {
await page.locator('#docsearch-recentSearches-item-0').locator('[title="Pin this search"]').click();
await expect(page.getByText('Pinned')).toBeVisible();
await page.locator('#docsearch-favoriteSearches-item-0').locator('[title="Remove this saved search"]').click();
await expect(docSearch.hits).not.toBeVisible();
});
});

View file

@ -6,11 +6,49 @@
padding: 0;
box-sizing: border-box;
}
.body-container {
--demo-background: radial-gradient(125% 125% at 50% 90%, #fff 40%, #7c3aed 100%);
--demo-border: rgba(255, 255, 255, 0.2);
--demo-card-background: #fff;
--demo-card-shadow: rgba(0, 0, 0, 0.1);
--demo-docsearch-button-background: #f7fafc;
--demo-docsearch-button-border: #e2e8f0;
--demo-docsearch-button-hover-border: #667eea;
--demo-docsearch-button-hover-shadow: rgba(102, 126, 234, 0.15);
--demo-docsearch-button-placeholder: #718096;
--demo-divider: #e2e8f0;
--demo-text-primary: #111;
--demo-text-secondary: #666;
--demo-theme-button-background: #fff;
--demo-theme-button-border: #d6d6e7;
--demo-theme-button-hover-border: #7c3aed;
min-height: 100vh;
background: var(--demo-background);
color: var(--demo-text-primary);
}
.body-container[data-theme='dark'] {
--demo-background: radial-gradient(125% 125% at 50% 90%, #161025 40%, #4c1d95 100%);
--demo-border: rgba(255, 255, 255, 0.1);
--demo-card-background: #1f1b2e;
--demo-card-shadow: rgba(0, 0, 0, 0.35);
--demo-docsearch-button-background: #15111f;
--demo-docsearch-button-border: #3b3550;
--demo-docsearch-button-hover-border: #a78bfa;
--demo-docsearch-button-hover-shadow: rgba(167, 139, 250, 0.2);
--demo-docsearch-button-placeholder: #c4b5fd;
--demo-divider: #3b3550;
--demo-text-primary: #f8f7ff;
--demo-text-secondary: #c9c3dc;
--demo-theme-button-background: #2d2540;
--demo-theme-button-border: #5b4a78;
--demo-theme-button-hover-border: #c4b5fd;
}
body {
font-family: 'TikTok Sans', 'sans-serif';
min-height: 100vh;
background: radial-gradient(125% 125% at 50% 90%, #fff 40%, #7c3aed 100%);
color: #333;
line-height: 1.6;
}
@ -34,17 +72,43 @@ body {
.app-title {
font-size: 2rem;
font-weight: 700;
color: black;
color: var(--demo-text-primary);
margin-block-end: 0;
}
.app-subtitle {
font-size: 1rem;
color: black;
color: #666;
color: var(--demo-text-secondary);
font-weight: 400;
}
.theme-switcher {
margin-block-start: 1rem;
border: 1px solid var(--demo-theme-button-border);
border-radius: 100vw;
background: var(--demo-theme-button-background);
color: var(--demo-text-primary);
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2.5rem;
block-size: 2.5rem;
position: absolute;
top: 2rem;
right: 4rem;
}
.theme-switcher svg {
inline-size: 1.25rem;
block-size: 1.25rem;
}
.theme-switcher:hover {
border-color: var(--demo-theme-button-hover-border);
}
main {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -54,17 +118,17 @@ main {
/* demo section styles */
.demo-section {
background: white;
background: var(--demo-card-background);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 30px var(--demo-card-shadow);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--demo-border);
}
.section-description {
text-align: center;
color: #666;
color: var(--demo-text-secondary);
font-family: 'Source Code Pro', monospace;
font-size: 0.9rem;
margin-block-end: 1rem;
@ -97,7 +161,7 @@ main {
.divider {
border: none;
height: 1px;
background: linear-gradient(90deg, transparent, #e2e8f0, transparent);
background: linear-gradient(90deg, transparent, var(--demo-divider), transparent);
margin: 0;
}
@ -122,8 +186,8 @@ main {
/* docsearch button customization */
.search-wrapper .DocSearch-Button {
margin: 0 !important;
background: #f7fafc !important;
border: 2px solid #e2e8f0 !important;
background: var(--demo-docsearch-button-background) !important;
border: 2px solid var(--demo-docsearch-button-border) !important;
border-radius: 12px !important;
padding: 12px 16px !important;
font-size: 16px !important;
@ -132,13 +196,13 @@ main {
}
.search-wrapper .DocSearch-Button:hover {
border-color: #667eea !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15) !important;
border-color: var(--demo-docsearch-button-hover-border) !important;
box-shadow: 0 4px 12px var(--demo-docsearch-button-hover-shadow) !important;
transform: translateY(-1px) !important;
}
.search-wrapper .DocSearch-Button-Placeholder {
color: #718096 !important;
color: var(--demo-docsearch-button-placeholder) !important;
}
/* add subtle animations */

View file

@ -1,7 +1,7 @@
/* eslint-disable react/react-in-jsx-scope */
import { version } from '@docsearch/react';
import { DocSearchSidepanel } from '@docsearch/react/sidepanel';
import type { JSX } from 'react';
import { useState, type JSX } from 'react';
import './App.css';
import '@docsearch/css/dist/style.css';
@ -17,84 +17,96 @@ import MultiIndex from './examples/multi-index';
import WHitComponent from './examples/w-hit-component';
import WTransformItems from './examples/w-hit-transformItems';
import WResultsFooter from './examples/w-results-footer';
import { MoonIcon, SunIcon } from './icons';
export type DemoTheme = 'dark' | 'light';
function App(): JSX.Element {
const [theme, setTheme] = useState<DemoTheme>('light');
const toggleTheme = (): void => {
setTheme((currentTheme) => (currentTheme === 'light' ? 'dark' : 'light'));
};
return (
<div className="body-container">
<div className="body-container" data-theme={theme}>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">DocSearch v{version}</h1>
<p className="app-subtitle">Experience the power of intelligent documentation search</p>
<button className="theme-switcher" type="button" onClick={toggleTheme}>
{theme === 'light' ? <MoonIcon /> : <SunIcon />}
</button>
</header>
<main>
<section className="demo-section">
<p className="section-description">default</p>
<div className="default-wrapper">
<Default />
<Default theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">basic search functionality</p>
<div className="search-wrapper">
<Basic />
<Basic theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">search with ask ai integration</p>
<div className="search-wrapper">
<BasicAskAI />
<BasicAskAI theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">custom hit rendering</p>
<div className="search-wrapper">
<WHitComponent />
<WHitComponent theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">transform items before rendering</p>
<div className="search-wrapper">
<WTransformItems />
<WTransformItems theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">multi index search</p>
<div className="search-wrapper">
<MultiIndex />
<MultiIndex theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">composable</p>
<div className="search-wrapper">
<Composable />
<Composable theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">dynamically imported modal</p>
<div className="search-wrapper">
<DynamicImportModal />
<DynamicImportModal theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">results footer component</p>
<div className="search-wrapper">
<WResultsFooter />
<WResultsFooter theme={theme} />
</div>
</section>
<section className="demo-section">
<p className="section-description">sidepanel hybrid</p>
<div className="search-wrapper">
<BasicHybrid />
<BasicHybrid theme={theme} />
</div>
</section>
</main>
@ -108,6 +120,7 @@ function App(): JSX.Element {
panel={{
suggestedQuestions: true,
}}
theme={theme}
/>
</div>
);

View file

@ -2,7 +2,9 @@
import { DocSearchAI } from '@docsearch/react';
import type { JSX } from 'react';
export default function BasicAskAI(): JSX.Element {
import type { DemoTheme } from '../App';
export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearchAI
indexName="docsearch"
@ -14,6 +16,7 @@ export default function BasicAskAI(): JSX.Element {
}}
insights={true}
translations={{ button: { buttonText: 'Search with Ask AI' } }}
theme={theme}
tools={{
printConsoleMessage: {
render({ message: { output } }) {

View file

@ -2,7 +2,9 @@
import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
export default function Basic(): JSX.Element {
import type { DemoTheme } from '../App';
export default function Basic({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch
indexName="docsearch"
@ -10,6 +12,7 @@ export default function Basic(): JSX.Element {
apiKey="24b09689d5b4223813d9b8e48563c8f6"
translations={{ button: { buttonText: 'Keyword search' } }}
insights={true}
theme={theme}
/>
);
}

View file

@ -3,9 +3,11 @@ import { DocSearch } from '@docsearch/core';
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import { type JSX } from 'react';
export default function Composable(): JSX.Element {
import type { DemoTheme } from '../App';
export default function Composable({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch>
<DocSearch theme={theme}>
<DocSearchButton translations={{ buttonText: 'Composable API' }} />
<DocSearchAskAiModal
indexName="docsearch"

View file

@ -2,6 +2,8 @@
import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
export default function DefaultExperience(): JSX.Element {
return <DocSearch indexName="docsearch" appId="PMZUYBQDAK" apiKey="24b09689d5b4223813d9b8e48563c8f6" />;
import type { DemoTheme } from '../App';
export default function DefaultExperience({ theme }: { theme: DemoTheme }): JSX.Element {
return <DocSearch indexName="docsearch" appId="PMZUYBQDAK" apiKey="24b09689d5b4223813d9b8e48563c8f6" theme={theme} />;
}

View file

@ -5,6 +5,8 @@ import { DocSearchButton } from '@docsearch/react/button';
import { useCallback, useRef, useState, type JSX } from 'react';
import { createPortal } from 'react-dom';
import type { DemoTheme } from '../App';
let DocSearchModal: typeof DocSearchAskAiModalType | null = null;
function importDocSearchModalIfNeeded(): Promise<void> {
@ -17,7 +19,7 @@ function importDocSearchModalIfNeeded(): Promise<void> {
});
}
function DocSearch(): JSX.Element {
function DocSearch({ theme }: { theme: DemoTheme }): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
const [isAskAiActive, setIsAskAiActive] = useState(false);
@ -76,6 +78,7 @@ function DocSearch(): JSX.Element {
<DocSearchButton
ref={searchButtonRef}
translations={{ buttonText: 'Dynamic modal search' }}
theme={theme}
onTouchStart={importDocSearchModalIfNeeded}
onFocus={importDocSearchModalIfNeeded}
onMouseOver={importDocSearchModalIfNeeded}
@ -96,6 +99,7 @@ function DocSearch(): JSX.Element {
initialScrollY={window.scrollY}
initialQuery={initialQuery}
isAskAiActive={isAskAiActive}
theme={theme}
onClose={closeModal}
onAskAiToggle={toggleAskAi}
/>,
@ -105,6 +109,6 @@ function DocSearch(): JSX.Element {
);
}
export default function DynamicImportModal(): JSX.Element {
return <DocSearch />;
export default function DynamicImportModal({ theme }: { theme: DemoTheme }): JSX.Element {
return <DocSearch theme={theme} />;
}

View file

@ -4,9 +4,11 @@ import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import { Sidepanel, SidepanelButton } from '@docsearch/sidepanel';
import type { JSX } from 'react';
export default function BasicHybrid(): JSX.Element {
import type { DemoTheme } from '../App';
export default function BasicHybrid({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch>
<DocSearch theme={theme}>
<DocSearchButton />
<DocSearchAskAiModal
indexName="docsearch"

View file

@ -2,7 +2,9 @@
import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
export default function MultiIndex(): JSX.Element {
import type { DemoTheme } from '../App';
export default function MultiIndex({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch
indices={[
@ -20,6 +22,7 @@ export default function MultiIndex(): JSX.Element {
apiKey="24b09689d5b4223813d9b8e48563c8f6"
translations={{ button: { buttonText: 'Multi index search' } }}
insights={true}
theme={theme}
/>
);
}

View file

@ -3,9 +3,11 @@ import { DocSearch } from '@docsearch/core';
import { SidepanelButton, Sidepanel } from '@docsearch/sidepanel';
import type { JSX } from 'react';
export default function SidepanelExample(): JSX.Element {
import type { DemoTheme } from '../App';
export default function SidepanelExample({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch>
<DocSearch theme={theme}>
<SidepanelButton variant="inline" />
<Sidepanel
indexName="docsearch"

View file

@ -2,6 +2,8 @@
import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
function CustomHit({ hit }: { hit: any }): JSX.Element {
return (
<a
@ -111,7 +113,7 @@ function CustomHit({ hit }: { hit: any }): JSX.Element {
);
}
export default function WHitComponent(): JSX.Element {
export default function WHitComponent({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch
indexName="docsearch"
@ -120,6 +122,7 @@ export default function WHitComponent(): JSX.Element {
insights={true}
translations={{ button: { buttonText: 'Search with custom hits' } }}
hitComponent={CustomHit}
theme={theme}
/>
);
}

View file

@ -2,6 +2,8 @@
import { DocSearchAI } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
// this type matches the structure of the provided example hit
/* type _DocSearchCustomHit = {
path: string;
@ -19,7 +21,7 @@ import type { JSX } from 'react';
_highlightResult: any;
}; */
export default function WTransformItems(): JSX.Element {
export default function WTransformItems({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearchAI
indexName="crawler_doc"
@ -56,6 +58,7 @@ export default function WTransformItems(): JSX.Element {
}));
}}
translations={{ button: { buttonText: 'Search with transformItems' } }}
theme={theme}
/>
);
}

View file

@ -4,6 +4,8 @@ import { DocSearch } from '@docsearch/react';
import type { InternalDocSearchHit } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
function ResultsFooterComponent({ state }: { state: AutocompleteState<InternalDocSearchHit> }): JSX.Element {
// Using JSX templates
return (
@ -15,7 +17,7 @@ function ResultsFooterComponent({ state }: { state: AutocompleteState<InternalDo
);
}
export default function WResultsFooter(): JSX.Element {
export default function WResultsFooter({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch
indexName="docsearch"
@ -24,6 +26,7 @@ export default function WResultsFooter(): JSX.Element {
insights={true}
resultsFooterComponent={ResultsFooterComponent}
translations={{ button: { buttonText: 'Search with results footer' } }}
theme={theme}
/>
);
}

View file

@ -0,0 +1,123 @@
/* eslint-disable react/react-in-jsx-scope */
export function MoonIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="none" />
<path
d="M108.11,28.11A96.09,96.09,0,0,0,227.89,147.89,96,96,0,1,1,108.11,28.11Z"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
</svg>
);
}
export function SunIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="none" />
<line
x1="128"
y1="40"
x2="128"
y2="16"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<circle
cx="128"
cy="128"
r="56"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="64"
y1="64"
x2="48"
y2="48"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="64"
y1="192"
x2="48"
y2="208"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="192"
y1="64"
x2="208"
y2="48"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="192"
y1="192"
x2="208"
y2="208"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="40"
y1="128"
x2="16"
y2="128"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="128"
y1="216"
x2="128"
y2="240"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
<line
x1="216"
y1="128"
x2="240"
y2="128"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="16"
/>
</svg>
);
}

View file

@ -4,12 +4,12 @@
--docsearch-primary-color: rgb(0, 61, 255);
--docsearch-soft-primary-color: rgba(0, 61, 255, 0.1);
--docsearch-subtle-color: rgb(214, 214, 231);
--docsearch-text-color: #36395a;
--docsearch-text-color: rgba(35, 38, 59, 1);
--docsearch-error-color: #ef5350;
--docsearch-success-color: #e8f5e9;
--docsearch-secondary-text-color: rgba(90, 94, 154, 1);
--docsearch-background-color: rgb(245, 245, 250);
--docsearch-spacing: 12px;
--docsearch-spacing: 1rem;
--docsearch-icon-stroke-width: 1.4;
--docsearch-focus-color: rgb(0, 95, 204);
--docsearch-highlight-color: rgb(0, 61, 255);
@ -27,8 +27,9 @@
/* modal */
--docsearch-modal-width: 800px;
--docsearch-modal-height: 600px;
--docsearch-modal-radius: 12px;
--docsearch-modal-variable-height: 60dvh;
--docsearch-modal-background: rgb(245, 246, 247);
--docsearch-modal-background: rgba(255, 255, 255, 1);
--docsearch-modal-shadow: rgba(0, 0, 0, 0.2) 0px 12px 28px 0px,
rgba(0, 0, 0, 0.1) 0px 2px 4px 0px,
rgba(255, 255, 255, 0.05) 0px 0px 0px 1px inset;
@ -46,6 +47,7 @@
--docsearch-hit-color: rgb(68, 73, 80);
--docsearch-hit-highlight-color: rgba(0, 61, 255, 0.1);
--docsearch-hit-background: #fff;
--docsearch-hit-focus-background: var(--docsearch-background-color);
/* key */
--docsearch-key-background: rgb(245, 245, 250);
@ -74,7 +76,11 @@
);
--docsearch-dropdown-menu-background: var(--docsearch-hit-background);
--docsearch-dropdown-menu-item-hover-background: var(--docsearch-modal-background);
--docsearch-dropdown-menu-item-hover-background: var(--docsearch-background-color);
/* Popover */
--docsearch-popover-background: #fff;
--docsearch-popover-arrow-color: rgb(230, 230, 240);
}
/* Darkmode */
@ -97,7 +103,8 @@ html[data-theme='dark'] {
--docsearch-searchbox-focus-background: #000000a6;
--docsearch-hit-color: rgb(190, 195, 201);
--docsearch-hit-shadow: none;
--docsearch-hit-background: rgb(9, 10, 17);
--docsearch-hit-background: var(--docsearch-modal-background);
--docsearch-hit-focus-background: rgb(9, 10, 17);
--docsearch-key-background: rgba(54, 57, 90, 1);
--docsearch-key-color: rgba(182, 183, 213, 1);
--docsearch-key-pressed-shadow: inset 0 2px 4px rgba(12, 13, 20, 0.4);
@ -113,8 +120,12 @@ html[data-theme='dark'] {
var(--docsearch-muted-color) 60%,
rgb(224, 227, 232) 95%
);
--docsearch-dropdown-menu-background: var(--docsearch-hit-background);
--docsearch-dropdown-menu-background: var(--docsearch-hit-focus-background);
--docsearch-dropdown-menu-item-hover-background: var(--docsearch-modal-background);
--docsearch-search-button-background: var(--docsearch-modal-background);
--docsearch-search-button-text-color: var(--docsearch-text-color);
/* Popover */
--docsearch-popover-background: var(--docsearch-hit-focus-background);
--docsearch-popover-arrow-color: var(--docsearch-hit-focus-background);
}

View file

@ -44,7 +44,7 @@
.DocSearch-Modal {
background: var(--docsearch-modal-background);
border-radius: 4px;
border-radius: var(--docsearch-modal-radius);
box-shadow: var(--docsearch-modal-shadow);
display: flex;
flex-direction: column;
@ -53,6 +53,107 @@
position: relative;
}
.DocSearch-Popover-Positioner {
width: var(--positioner-width);
height: var(--positioner-height);
max-width: var(--available-width)
}
.DocSearch-Popover-Popup {
width: var(--popup-width, auto);
height: var(--popup-height, auto);
max-width: 500px;
position: relative;
background-color: var(--docsearch-popover-background);
z-index: 999;
box-shadow: 0px 0px 0px 1px rgba(33, 36, 61, 0.05), 0px 4px 8px -2px rgba(33, 36, 61, 0.25);
border-radius: var(--docsearch-border-radius);
padding-inline: 1rem;
padding-block: 1rem;
transform-origin: var(--transform-origin);
outline: none;
}
.DocSearch-Popover-Title {
color: var(--docsearch-text-color);
font-size: 0.875em;
font-weight: 600;
line-height: 1.25em;
}
.DocSearch-Popover-Arrow {
--arrow-height: 8px;
--arrow-width: 18px;
position: relative;
display: block;
width: var(--arrow-width);
height: var(--arrow-height);
overflow: clip;
}
.DocSearch-Popover-Arrow[data-side="top"] {
bottom: calc(var(--arrow-height) * -1);
rotate: 180deg;
}
.DocSearch-Popover-Arrow[data-side="bottom"] {
top: calc(var(--arrow-height) * -1);
rotate: 0deg;
}
.DocSearch-Popover-Arrow[data-side="left"] {
right: calc(calc(var(--arrow-width) * 0.67) * -1);
rotate: 90deg;
}
.DocSearch-Popover-Arrow[data-side="right"] {
left: calc(calc(var(--arrow-width) * 0.67) * -1);
rotate: -90deg;
}
.DocSearch-Popover-Arrow::before {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 50%;
box-sizing: border-box;
width: calc(var(--arrow-height) * sqrt(2));
height: calc(var(--arrow-height) * sqrt(2));
background-color: var(--docsearch-popover-background);
border: 1px solid var(--docsearch-popover-arrow-color);
transform: translate(-50%, 50%) rotate(45deg);
}
.DocSearch-Popover-Close {
position: absolute;
top: 1em;
right: 1rem;
min-height: 1.5em;
min-width: 1.5em;
background: none;
border: none;
display: flex;
align-items: center;
justify-content: center;
appearance: none;
inset-inline-end: 0;
stroke-width: var(--docsearch-icon-stroke-width);
border-radius: var(--docsearch-border-radius);
cursor: pointer;
color: var(--docsearch-text-color);
}
.DocSearch-Popover-Close:hover {
color: var(--docsearch-highlight-color);
background: var(--docsearch-soft-primary-color);
}
.DocSearch-Popover-Close svg {
height: 1rem;
width: 1rem;
}
/* Modal Searchbox */
.DocSearch-SearchBar {
@ -67,7 +168,7 @@
align-items: center;
background: var(--docsearch-searchbox-focus-background);
border-block-end: 1px solid var(--docsearch-subtle-color);
border-radius: 4px 4px 0 0;
border-radius: var(--docsearch-modal-radius) var(--docsearch-modal-radius) 0 0;
display: flex;
min-height: var(--docsearch-searchbox-initial-height);
height: var(
@ -89,8 +190,8 @@
color: var(--docsearch-text-color);
flex: 1;
font: inherit;
font-size: 1.2em;
font-weight: 300;
font-size: 0.875rem;
font-weight: 400;
height: 100%;
outline: none;
padding-block-start: 0px;
@ -101,8 +202,13 @@
overflow-y: hidden; /* js toggles to auto when exceeding max */
}
.DocSearch-Modal-heading {
align-items: center;
display: inline-flex;
}
.DocSearch-Input::placeholder {
color: var(--docsearch-muted-color);
color: var(--docsearch-secondary-text-color);
opacity: 1; /* Firefox */
}
@ -156,6 +262,9 @@
color: var(--docsearch-highlight-color);
display: flex;
justify-content: center;
height: 2rem;
width: 2rem;
margin-left: -0.5rem;
}
.DocSearch-LoadingIndicator {
@ -209,8 +318,8 @@
.DocSearch-LoadingIndicator svg,
.DocSearch-MagnifierLabel svg {
color: var(--docsearch-icon-color);
height: 24px;
width: 24px;
height: 1rem;
width: 1rem;
}
/* highlight icon color when search input is focused */
@ -259,6 +368,7 @@
scrollbar-color: var(--docsearch-muted-color)
var(--docsearch-modal-background);
scrollbar-width: thin;
scrollbar-gutter: stable;
}
.DocSearch-Dropdown::-webkit-scrollbar {
@ -369,7 +479,7 @@
background: var(--docsearch-hit-background);
border-radius: 4px;
display: block;
padding-inline-start: var(--docsearch-spacing);
padding-inline-start: 0.5rem;
width: 100%;
cursor: pointer;
}
@ -378,13 +488,26 @@
background: var(--docsearch-modal-background);
color: var(--docsearch-text-color);
font-size: 0.9em;
font-weight: 600;
font-weight: 400;
line-height: 32px;
margin: 0 -4px;
padding: 8px 4px 4px;
position: sticky;
inset-block-start: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
}
.DocSearch-Hit-source svg {
width: 18px;
height: 18px;
stroke-width: var(--docsearch-icon-stroke-width);
}
.DocSearch-Dropdown-Container ul.DocSearch-Hits-padded {
padding-inline-start: 1em;
}
.DocSearch-Hit-Tree {
@ -397,11 +520,11 @@
.DocSearch-Hit[aria-selected='true'] a,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit--AskAI {
background-color: var(--docsearch-hit-highlight-color);
background-color: var(--docsearch-hit-focus-background);
}
.DocSearch-Conversation-History .DocSearch-Hit[aria-selected='true'] a {
background-color: var(--docsearch-hit-background);
background-color: var(--docsearch-hit-focus-background);
}
.DocSearch-Hit mark {
@ -416,23 +539,44 @@
display: flex;
flex-direction: row;
height: var(--docsearch-hit-height);
padding: 0 var(--docsearch-spacing) 0 0;
padding: 0 0.5rem 0 0;
}
.DocSearch-Hit-icon {
color: var(--docsearch-secondary-text-color);
height: 20px;
height: 24px;
stroke-width: var(--docsearch-icon-stroke-width);
width: 24px;
align-self: flex-start;
margin-block-start: 0.5em;
}
.DocSearch-Hit-icon svg {
height: 20px;
width: 20px;
}
.DocSearch-Hit-icon--small {
height: 1rem;
width: 1rem;
}
.DocSearch-Hit-icon--small svg {
height: 1rem;
width: 1rem;
}
.DocSearch-Hit-action,
.DocSearch-Hit-action-button {
height: 22px;
width: 22px;
}
.DocSearch-Hit-action {
align-items: center;
color: var(--docsearch-muted-color);
display: flex;
height: 22px;
stroke-width: var(--docsearch-icon-stroke-width);
width: 22px;
}
.DocSearch-Hit-action svg {
@ -441,6 +585,12 @@
width: 18px;
}
.DocSearch-Hit-action-button--pin svg {
margin-inline-start: 2px;
height: 14px;
width: 14px;
}
.DocSearch-Hit-action + .DocSearch-Hit-action {
margin-inline-start: 6px;
}
@ -453,12 +603,14 @@
color: inherit;
cursor: pointer;
padding: 2px;
display: none;
}
svg.DocSearch-Hit-Select-Icon {
display: none;
}
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-action-button,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-Select-Icon {
display: block;
}
@ -477,11 +629,6 @@ svg.DocSearch-Hit-Select-Icon {
transition: background-color 0.1s ease-in;
}
.DocSearch-Hit-action-button:hover path,
.DocSearch-Hit-action-button:focus path {
fill: #fff;
}
.DocSearch-Hit-content-wrapper {
display: flex;
flex: 1 1 auto;
@ -490,18 +637,25 @@ svg.DocSearch-Hit-Select-Icon {
justify-content: center;
line-height: 1.2em;
margin: 0 8px;
overflow-x: hidden;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
width: 80%;
gap: 4px;
}
.DocSearch-Hit-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
font-size: 0.9em;
}
@media screen and (min-width: 768px) {
.DocSearch-Hit-title {
max-width: 584px;
}
}
.DocSearch-Hit-path {
color: var(--docsearch-secondary-text-color);
font-size: 0.75em;
@ -513,9 +667,11 @@ svg.DocSearch-Hit-Select-Icon {
color: var(--docsearch-text-color);
}
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-action,
.DocSearch-Hit[aria-selected='true'] mark,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-icon {
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-action {
color: var(--docsearch-secondary-text-color);
}
.DocSearch-Hit[aria-selected='true'] mark {
color: var(--docsearch-highlight-color);
}
@ -629,7 +785,7 @@ svg.DocSearch-Hit-Select-Icon {
align-items: center;
background: var(--docsearch-footer-background);
border-block-start: 1px solid var(--docsearch-subtle-color);
border-radius: 0 0 4px 4px;
border-radius: 0 0 var(--docsearch-modal-radius) var(--docsearch-modal-radius);
display: flex;
flex-direction: row-reverse;
flex-shrink: 0;
@ -670,14 +826,19 @@ svg.DocSearch-Hit-Select-Icon {
box-shadow: none !important;
}
.DocSearch-Commands-Key svg {
height: 1rem;
width: 1rem;
}
.DocSearch-Commands-Key:last-of-type {
margin-inline-end: 8px;
}
.DocSearch-Escape-Key {
font-weight: 300;
font-size: 10px;
line-height: 16px;
font-size: 0.5rem;
line-height: 1.2;
letter-spacing: normal;
text-align: center;
text-transform: uppercase;
@ -707,43 +868,31 @@ assistive tech users */
/* Ask AI Button */
.DocSearch-Hit-AskAIButton {
color: var(--docsearch-text-color);
display: flex;
align-items: center;
flex-direction: row;
height: 40px;
}
.DocSearch-Hit-AskAIButton-icon {
color: var(--docsearch-icon-color);
margin-inline-end: 12px;
flex-shrink: 0;
margin-inline-end: 8px;
margin-block-start: 0;
align-self: unset;
}
.DocSearch-Hit-AskAIButton-title {
display: flex;
flex: 1 1 auto;
font-weight: 400;
overflow-x: hidden;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
width: 80%;
gap: 4px;
color: var(--docsearch-hit-color);
font-size: 0.9em;
}
.DocSearch-Hit-AskAIButton-title-query {
white-space: nowrap;
overflow: hidden;
background: none;
margin-inline-start: 4px;
text-overflow: ellipsis;
}
.DocSearch-Hit-AskAIButton-title mark {
text-decoration: none;
}
@keyframes fade-in {
0% {
opacity: 0;
@ -770,11 +919,19 @@ assistive tech users */
display: flex;
font-size: 0.6em;
font-weight: 300;
padding-block: 1.5em 0.5em;
padding-block: 0.5em 1em;
padding-inline: 0;
text-align: start;
text-align: center;
margin: 0;
align-self: flex-start;
align-self: center;
align-items: center;
gap: 1ch;
color: var(--docsearch-secondary-text-color);
}
.DocSearch-AskAiScreen-Disclaimer svg {
height: 1rem;
width: 1rem;
}
.DocSearch-AskAiScreen-Body {
@ -793,21 +950,23 @@ assistive tech users */
display: flex;
flex-direction: column;
width: 100%;
gap: 1em;
gap: 0.75em;
font-size: 0.8em;
margin-block-end: 8px;
background: var(--docsearch-hit-background);
padding: 24px;
padding: 16px;
color: var(--docsearch-text-color);
border-radius: 4px;
align-self: flex-start;
}
.DocSearch-AskAiScreen-Query {
font-size: 1.25em;
.DocSearch-AskAiScreen-Message--user {
align-self: flex-end;
background-color: var(--docsearch-background-color);
padding: 0.5rem 0.75rem;
border-radius: var(--docsearch-modal-radius);
font-size: 14px;
font-weight: 400;
line-break: loose;
line-height: 1.4;
font-weight: 600;
margin: 0;
}
@ -1015,6 +1174,7 @@ assistive tech users */
.DocSearch-Feedback-Panel-Cancel {
background: var(--docsearch-modal-background);
color: var(--docsearch-text-color);
}
.DocSearch-Feedback-Panel-Cancel:hover {
@ -1055,16 +1215,17 @@ assistive tech users */
.DocSearch-AskAiScreen-MessageContent {
display: flex;
flex-direction: column;
row-gap: 1em;
row-gap: 0.75rem;
}
.DocSearch-AskAiScreen-Error {
padding: var(--docsearch-spacing);
padding: 1em;
display: flex;
padding-inline: 1em;
padding-block: 1em;
gap: 8px;
color: var(--docsearch-error-color);
background-color: rgb(239 83 80 / 0.1);
border-radius: 4px;
background-color: var(--docsearch-background-color);
border-radius: var(--docsearch-modal-radius);
color: var(--docsearch-text-color);
font-size: 1em;
font-weight: 400;
flex-direction: row;
@ -1072,12 +1233,14 @@ assistive tech users */
.DocSearch-AskAiScreen-Error svg {
margin-top: 0.25rem;
stroke-width: var(--docsearch-icon-stroke-width);
height: 1rem;
width: 1rem;
}
.DocSearch-AskAiScreen-Error svg,
.DocSearch-AskAiScreen-MessageContent-Tool svg {
width: 16px;
height: 16px;
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0;
}
@ -1092,12 +1255,8 @@ assistive tech users */
}
.DocSearch-AskAiScreen-Error-Title {
font-weight: 700;
margin-bottom: 4px;
}
.DocSearch-AskAiScreen-Error .DocSearch-Markdown-Content {
color: var(--docsearch-error-color);
font-weight: 600;
font-size: 1em;
}
/* Thread Depth Error - positioned below input */
@ -1219,7 +1378,7 @@ assistive tech users */
}
.DocSearch-AskAiScreen-ExchangesList {
gap: 24px;
gap: 12px;
margin: 8px 0;
display: flex;
flex-direction: column;
@ -1400,6 +1559,51 @@ assistive tech users */
font-weight: 600;
}
.DocSearch-AskAi-Thinking-Skeleton {
--shimmer-gradient: rgb(170, 193, 250) 0%, rgb(245, 251, 255) 50%, rgb(170, 193, 250) 100%;
--shimmer-bg: linear-gradient(
95deg,
var(--shimmer-gradient)
);
display: block;
height: 0.875rem;
width: 100%;
border-radius: 100px;
background-color: rgba(187, 209, 255, 1);
}
html[data-theme='dark'] .DocSearch-AskAi-Thinking-Skeleton {
--shimmer-gradient: rgb(31, 64, 195) 0%, rgb(160, 185, 250) 50%, rgb(31, 64, 195) 100%;
}
.DocSearch-AskAi-Thinking-Skeleton.shimmer {
background-clip: unset;
}
.DocSearch-AskAiScreen-MessageContent-Thinking {
--shimmer-gradient: rgb(40, 55, 190) 0%, rgb(150, 162, 240) 50%, rgb(40, 55, 190) 100%;
--shimmer-bg: linear-gradient(
95deg,
var(--shimmer-gradient)
);
display: flex;
flex-direction: column;
gap: 0.875rem;
font-size: 1em;
}
html[data-theme='dark'] .DocSearch-AskAiScreen-MessageContent-Thinking {
--shimmer-gradient: rgb(108, 145, 255) 0%, rgb(200, 218, 255) 50%, rgb(108, 145, 255) 100%;
}
.DocSearch-AskAiScreen-MessageContent-Thinking .DocSearch-AskAi-Thinking-Skeleton {
max-width: 40%;
}
.DocSearch-AskAiScreen-MessageContent-Thinking .DocSearch-AskAi-Thinking-Skeleton--short {
max-width: 25%;
}
.DocSearch-AskAiScreen-MessageContent-Reasoning {
display: flex;
align-items: center;
@ -1416,8 +1620,9 @@ assistive tech users */
display: flex;
align-items: center;
width: 100%;
color: var(--docsearch-muted-color);
color: var(--docsearch-secondary-text-color);
line-height: 1.2;
font-size: 0.9rem;
}
.DocSearch-AskAiScreen-MessageContent-Tool.Tool--Result {
@ -1430,7 +1635,6 @@ assistive tech users */
}
.DocSearch-AskAiScreen-MessageContent-Tool-Query {
color: var(--docsearch-muted-color);
transition: box-shadow 0.2s ease;
}
@ -1461,7 +1665,7 @@ assistive tech users */
.shimmer {
background: var(--shimmer-bg);
background-size: 200% auto;
background-size: 200% 100%;
background-clip: text;
display: flex;
-webkit-background-clip: text;
@ -1471,12 +1675,18 @@ assistive tech users */
pointer-events: none;
}
@media screen and (prefers-reduced-motion: reduce) {
.shimmer {
animation: none;
}
}
@keyframes shimmerText {
0% {
background-position: 200% 0;
background-position-x: 200%;
}
100% {
background-position: -200% 0;
background-position-x: 0%;
}
}
@ -1576,6 +1786,89 @@ assistive tech users */
}
}
.DocSearch-AskAiScreen-Sources-Action {
display: flex;
align-items: center;
gap: 1ch;
border-radius: 100vw;
border: 1px solid var(--docsearch-subtle-color);
background: transparent;
padding-inline: 0.75em;
padding-block: 0.375em;
cursor: pointer;
user-select: none;
}
.DocSearch-AskAiScreen-Sources-Action-icon {
color: var(--docsearch-secondary-text-color);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 100vw;
border: 1px solid var(--docsearch-subtle-color);
height: 1.8em;
width: 1.8em;
}
.DocSearch-AskAiScreen-Sources-Action-icon svg {
height: 1rem;
width: 1rem;
stroke-width: var(--docsearch-icon-stroke-width);
}
.DocSearch-AskAiScreen-Sources-Action-text {
margin-block-start: 2px;
font-size: 1em;
line-height: 1.15em;
white-space: nowrap;
color: var(--docsearch-secondary-text-color);
font-weight: 300;
}
.DocSearch-AskAiScreen-Sources {
padding-inline: 0;
margin-top: 0.5rem;
color: var(--docsearch-text-color);
list-style-type: none;
display: flex;
flex-direction: column;
gap: 0.375em;
}
.DocSearch-AskAiScreen-Sources-source svg {
stroke-width: var(--docsearch-icon-stroke-width);
height: 1.25rem;
width: 1.25rem;
color: var(--docsearch-icon-color);
}
.DocSearch-AskAiScreen-Sources-source a {
display: grid;
grid-template-columns: 1.25rem 1fr;
align-items: center;
gap: 0.375em;
font-size: 0.875rem;
color: var(--docsearch-text-color);
text-decoration: none;
}
.DocSearch-AskAiScreen-Sources-source a:hover {
text-decoration: underline;
}
.DocSearch-AskAiScreen-Sources-source-title {
white-space: nowrap;
max-width: 15rem;
overflow-x: hidden;
text-overflow: ellipsis;
}
@media screen and (min-width: 768px) {
.DocSearch-AskAiScreen-Sources-source-title {
max-width: 20rem;
}
}
/* code snippet wrapper */
.DocSearch-CodeSnippet {
position: relative;

View file

@ -349,6 +349,7 @@ html[data-theme='dark'] .DocSearch-SidepanelButton.inline:hover {
overflow-y: overlay;
scrollbar-color: var(--docsearch-sidepanel-scrollbar-color) var(--docsearch-sidepanel-scrollbar-bg);
scrollbar-width: thin;
scrollbar-gutter: stable;
}
.DocSearch-Sidepanel-Content::-webkit-scrollbar {
@ -644,14 +645,10 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover {
}
.DocSearch-Sidepanel .DocSearch-AskAiScreen-Response {
padding: 1rem;
padding: 0;
font-size: 0.95em;
}
.DocSearch-Sidepanel .DocSearch-AskAiScreen-Query {
font-size: 1.25rem;
}
.DocSearch-Sidepanel .DocSearch-Markdown-Content {
font-size: 0.875rem;
line-height: 1.25rem;

View file

@ -52,6 +52,7 @@
},
"dependencies": {
"@algolia/autocomplete-core": "1.19.2",
"@base-ui/react": "^1.5.0",
"@docsearch/core": "4.6.0",
"@docsearch/css": "4.6.0"
},

View file

@ -4,8 +4,9 @@ import React, { type JSX, useMemo } from 'react';
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
import type { AskAiScreenStateProps } from './AskAiScreenState';
import { FeedbackActions } from './components/FeedbackActions';
import { SourcesPanel } from './components/SourcesPanel';
import { ToolCall, type ToolCallTranslations } from './components/ToolCall';
import { AlertIcon, LoadingIcon } from './icons';
import { AlertIcon, LoadingIcon, SparklesIcon } from './icons';
import { MemoizedMarkdown } from './MemoizedMarkdown';
import type { StoredSearchPlugin } from './stored-searches';
import type { InternalDocSearchHit, OnAskAiFeedback, StoredAskAiState } from './types';
@ -128,8 +129,12 @@ export interface Exchange {
assistantMessage: AIMessage | null;
}
function AskAiScreenHeader({ disclaimerText }: AskAiScreenHeaderProps): JSX.Element {
return <p className="DocSearch-AskAiScreen-Disclaimer">{disclaimerText}</p>;
function AskAiScreenDisclaimer({ disclaimerText }: AskAiScreenHeaderProps): JSX.Element {
return (
<p className="DocSearch-AskAiScreen-Disclaimer">
<SparklesIcon /> {disclaimerText}
</p>
);
}
interface AskAiExchangeCardProps {
@ -159,7 +164,11 @@ function AskAiExchangeCard({
}: AskAiExchangeCardProps): JSX.Element {
const { userMessage, assistantMessage } = exchange;
const { stoppedStreamingText = 'You stopped this response', errorTitleText = 'Chat error' } = translations;
const {
stoppedStreamingText = 'You stopped this response',
errorTitleText = 'Chat error',
relatedSourcesText,
} = translations;
const toolCallTranslations = useMemo(() => toToolCallTranslations(translations), [translations]);
@ -195,8 +204,8 @@ function AskAiExchangeCard({
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{loadingStatus === 'error' && askAiError && isLastExchange && !isThreadDepth && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
<AlertIcon />
<div className="DocSearch-AskAiScreen-Error" role="alert">
<AlertIcon aria-hidden="true" />
<div className="DocSearch-AskAiScreen-Error-Content">
<h4 className="DocSearch-AskAiScreen-Error-Title">{errorTitleText}</h4>
<MemoizedMarkdown
@ -209,8 +218,10 @@ function AskAiExchangeCard({
</div>
)}
{isThinking && (
<div className="DocSearch-AskAiScreen-MessageContent-Reasoning">
<div className="DocSearch-AskAiScreen-MessageContent-Thinking" role="status">
<span className="shimmer">{translations.thinkingText || 'Thinking...'}</span>
<span className="DocSearch-AskAi-Thinking-Skeleton shimmer" />
<span className="DocSearch-AskAi-Thinking-Skeleton DocSearch-AskAi-Thinking-Skeleton--short shimmer" />
</div>
)}
{displayParts.map((part, idx) => {
@ -281,6 +292,7 @@ function AskAiExchangeCard({
{wasStopped && <p className="DocSearck-AskAiScreen-MessageContent-Stopped">{stoppedStreamingText}</p>}
</div>
<div className="DocSearch-AskAiScreen-Answer-Footer">
<SourcesPanel links={urlsToDisplay} titleText={relatedSourcesText} />
<FeedbackActions
id={messageId}
showActions={showActions}
@ -291,46 +303,13 @@ function AskAiExchangeCard({
/>
</div>
</div>
{/* Sources for this exchange */}
{urlsToDisplay.length > 0 ? (
<AskAiSourcesPanel urlsToDisplay={urlsToDisplay} relatedSourcesText={translations.relatedSourcesText} />
) : null}
</div>
);
}
interface AskAiSourcesPanelProps {
urlsToDisplay: Array<{ url: string; title?: string }>;
relatedSourcesText?: string;
}
export function AskAiSourcesPanel({ urlsToDisplay, relatedSourcesText }: AskAiSourcesPanelProps): JSX.Element {
return (
<div className="DocSearch-AskAiScreen-RelatedSources">
<p className="DocSearch-AskAiScreen-RelatedSources-Title">{relatedSourcesText || 'Related sources'}</p>
<div className="DocSearch-AskAiScreen-RelatedSources-List">
{urlsToDisplay.length > 0 &&
urlsToDisplay.map((link) => (
<a
key={link.url}
href={link.url}
className="DocSearch-AskAiScreen-RelatedSources-Item-Link"
target="_blank"
rel="noopener noreferrer"
>
<RelatedSourceIcon />
<span>{link.title || link.url}</span>
</a>
))}
</div>
</div>
);
}
export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null {
const {
disclaimerText = 'Answers are generated with AI which can make mistakes. Verify responses.',
disclaimerText = 'Answers are generated with AI which can make mistakes.',
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
startNewConversationButtonText = 'Start a new conversation',
} = translations;
@ -394,7 +373,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
</div>
)}
<AskAiScreenHeader disclaimerText={disclaimerText} />
<AskAiScreenDisclaimer disclaimerText={disclaimerText} />
<div className="DocSearch-AskAiScreen-Body">
<div className="DocSearch-AskAiScreen-ExchangesList">
@ -421,23 +400,3 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
</div>
);
}
function RelatedSourceIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" x2="20" y1="9" y2="9" />
<line x1="4" x2="20" y1="15" y2="15" />
<line x1="10" x2="8" y1="3" y2="21" />
<line x1="16" x2="14" y1="3" y2="21" />
</svg>
);
}

View file

@ -6,13 +6,17 @@ import { CloseIcon, SparklesIcon } from './icons';
import { Results } from './Results';
import type { ResultsScreenTranslations } from './ResultsScreen';
import type { InternalDocSearchHit } from './types';
import { getCollection } from './utils';
type ConversationHistoryScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
translations?: ResultsScreenTranslations;
};
export function ConversationHistoryScreen({ onAskAiToggle, ...props }: ConversationHistoryScreenProps): JSX.Element {
const collection = React.useMemo(() => props.state.collections[2], [props.state]);
export function ConversationHistoryScreen({
onAskAiToggle,
...props
}: ConversationHistoryScreenProps): JSX.Element | null {
const collection = React.useMemo(() => getCollection(props.state, 'recentConversations'), [props.state]);
React.useEffect(() => {
if (!collection || collection.items.length === 0) {
@ -20,6 +24,10 @@ export function ConversationHistoryScreen({ onAskAiToggle, ...props }: Conversat
}
}, [collection, onAskAiToggle]);
if (!collection) {
return null;
}
return (
<div className="DocSearch-Dropdown-Container DocSearch-Conversation-History">
<Results

View file

@ -312,7 +312,7 @@ export function DocSearchAskAiModal({
onAskAiToggle,
})
: [];
return [...noQuerySources, ...recentConversationSource];
return [...recentConversationSource, ...noQuerySources];
}
const querySourcesState: BuildQuerySourcesState = {

View file

@ -3,9 +3,9 @@ import { useTheme } from '@docsearch/core/useTheme';
import React, { useEffect, useState, type JSX } from 'react';
import { getKeyboardShortcuts } from './constants/keyboardShortcuts';
import { usePlatformKeys } from './hooks/usePlatformKeys';
import { SearchIcon } from './icons/SearchIcon';
import type { DocSearchTheme } from './types';
import { ACTION_KEY_APPLE, ACTION_KEY_DEFAULT, isAppleDevice } from './utils';
export type ButtonTranslations = Partial<{
buttonText: string;
@ -22,19 +22,9 @@ export const DocSearchButton = React.forwardRef<HTMLButtonElement, DocSearchButt
({ translations = {}, keyboardShortcuts, ...props }, ref) => {
const { buttonText = 'Search', buttonAriaLabel = 'Search' } = translations;
const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts);
const { actionKeyReactsTo, actionKeyAltText, actionKeyLabel, key } = usePlatformKeys();
const [key, setKey] = useState<typeof ACTION_KEY_APPLE | typeof ACTION_KEY_DEFAULT | null>(null);
useTheme({ theme: props.theme });
useEffect(() => {
if (typeof navigator !== 'undefined') {
isAppleDevice() ? setKey(ACTION_KEY_APPLE) : setKey(ACTION_KEY_DEFAULT);
}
}, []);
const [actionKeyReactsTo, actionKeyAltText, actionKeyLabel] =
key === ACTION_KEY_DEFAULT
? ([ACTION_KEY_DEFAULT, 'Control', 'Ctrl'] as const)
: (['Meta', 'Meta', '⌘'] as const);
const isCtrlCmdKEnabled = resolvedShortcuts['Ctrl/Cmd+K'];
const shortcut = `${actionKeyAltText}+k`;

View file

@ -2,6 +2,7 @@ import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/auto
import React, { type JSX } from 'react';
import type { DocSearchProps } from './DocSearch';
import { useRelativeFormattedDate } from './hooks/useRelativeFormattedDate';
import { SparklesIcon } from './icons/SparklesIcon';
import { Snippet } from './Snippet';
import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
@ -10,6 +11,7 @@ import { sanitizeUserInput } from './utils/sanitize';
export type ResultsTranslations = Partial<{
askAiPlaceholder: string;
noResultsAskAiPlaceholder: string;
recentConversationTimestampFallback: string;
}>;
interface ResultsProps<TItem extends BaseItem>
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
@ -21,6 +23,7 @@ interface ResultsProps<TItem extends BaseItem>
onItemClick: (item: TItem, event: KeyboardEvent | MouseEvent) => void;
hitComponent: DocSearchProps['hitComponent'];
state: AutocompleteState<TItem>;
sourceIcon?: JSX.Element;
}
export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TItem>): JSX.Element | null {
@ -45,8 +48,9 @@ export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
if (props.collection.source.sourceId === 'askAI') {
return (
<section className="DocSearch-AskAi-Section">
<ul {...props.getListProps({ source: props.collection.source })}>
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">Ask AI Assistant</div>
<ul className="DocSearch-Hits-padded" {...props.getListProps({ source: props.collection.source })}>
<AskAiButton item={props.collection.items[0]} translations={props.translations} {...props} />
</ul>
</section>
@ -56,8 +60,11 @@ export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
if (props.collection.source.sourceId === 'recentConversations') {
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">{decodedTitle}</div>
<ul {...props.getListProps({ source: props.collection.source })}>
<div className="DocSearch-Hit-source">
<SparklesIcon />
{decodedTitle}
</div>
<ul className="DocSearch-Hits-padded" {...props.getListProps({ source: props.collection.source })}>
{props.collection.items.map((item, index) => {
return <Result key={[props.title, item.objectID].join(':')} item={item} index={index} {...props} />;
})}
@ -68,9 +75,12 @@ export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">{decodedTitle}</div>
<div className="DocSearch-Hit-source">
{props.sourceIcon ?? null}
{decodedTitle}
</div>
<ul {...props.getListProps({ source: props.collection.source })}>
<ul className="DocSearch-Hits-padded" {...props.getListProps({ source: props.collection.source })}>
{props.collection.items.map((item, index) => {
return <Result key={[props.title, item.objectID].join(':')} item={item} index={index} {...props} />;
})}
@ -93,8 +103,13 @@ function Result<TItem extends StoredDocSearchHit>({
onItemClick,
collection,
hitComponent,
translations = {},
}: ResultProps<TItem>): JSX.Element {
const Hit = hitComponent!;
const { recentConversationTimestampFallback = 'A while ago' } = translations;
const storedDate = item.type === 'askAI' && item.hierarchy.lvl2 ? new Date(item.hierarchy.lvl2) : null;
const relativeDate = useRelativeFormattedDate(storedDate);
return (
<li
className={[
@ -115,16 +130,17 @@ function Result<TItem extends StoredDocSearchHit>({
<div className="DocSearch-Hit-Container">
{renderIcon({ item, index })}
{item.hierarchy[item.type] && item.type === 'lvl1' && (
<div className="DocSearch-Hit-content-wrapper">
<Snippet className="DocSearch-Hit-title" hit={item} attribute="hierarchy.lvl1" />
{item.content && <Snippet className="DocSearch-Hit-path" hit={item} attribute="content" />}
</div>
)}
{item.type === 'askAI' && (
<div className="DocSearch-Hit-content-wrapper">
<span className="DocSearch-Hit-title">{sanitizeUserInput(item.hierarchy.lvl1 || '')}</span>
<span className="DocSearch-Hit-path">{relativeDate || recentConversationTimestampFallback}</span>
</div>
)}
{item.hierarchy[item.type] && item.type === 'lvl1' && (
<div className="DocSearch-Hit-content-wrapper">
<Snippet className="DocSearch-Hit-title" hit={item} attribute="hierarchy.lvl1" />
{item.hierarchy.lvl0 && <Snippet className="DocSearch-Hit-path" hit={item} attribute="hierarchy.lvl0" />}
</div>
)}
@ -164,15 +180,9 @@ function AskAiButton<TItem extends StoredDocSearchHit>({
item,
getItemProps,
onItemClick,
translations,
collection,
...props
}: AskAiButtonProps<TItem>): JSX.Element {
const { askAiPlaceholder = 'Ask AI: ', noResultsAskAiPlaceholder = "Didn't find it in the docs? Ask AI to help: " } =
translations || {};
const noKeywordResults = props.state.collections.length === 1;
const placeholder = noKeywordResults ? noResultsAskAiPlaceholder : askAiPlaceholder;
}: AskAiButtonProps<TItem>): JSX.Element | null {
if (!item.query) return null;
return (
<li
@ -191,8 +201,7 @@ function AskAiButton<TItem extends StoredDocSearchHit>({
<SparklesIcon />
</div>
<div className="DocSearch-Hit-AskAIButton-title">
<span className="DocSearch-Hit-AskAIButton-title-highlight">{placeholder}</span>
<mark className="DocSearch-Hit-AskAIButton-title-query">{String(item.query || '')}</mark>
<span className="DocSearch-Hit-AskAIButton-title-query">{item.query}</span>
</div>
</div>
</div>

View file

@ -4,11 +4,11 @@ import { SelectIcon, SourceIcon } from './icons';
import { Results } from './Results';
import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types';
import { removeHighlightTags } from './utils';
export type ResultsScreenTranslations = Partial<{
askAiPlaceholder: string;
noResultsAskAiPlaceholder: string;
resultsSectionTitle: string;
}>;
type ResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
@ -16,6 +16,8 @@ type ResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translat
};
export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProps): JSX.Element {
const { resultsSectionTitle = 'Results' } = translations;
return (
<div className="DocSearch-Dropdown-Container">
{props.state.collections.map((collection) => {
@ -23,39 +25,17 @@ export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProp
return null;
}
const title = removeHighlightTags(collection.items[0]);
return (
<Results
{...props}
key={collection.source.sourceId}
translations={translations}
title={title}
title={resultsSectionTitle}
collection={collection}
renderIcon={({ item, index }) => (
<>
{item.__docsearch_parent && (
<svg className="DocSearch-Hit-Tree" viewBox="0 0 24 54">
<g
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
{item.__docsearch_parent !== collection.items[index + 1]?.__docsearch_parent ? (
<path d="M8 6v21M20 27H8.3" />
) : (
<path d="M8 6v42M20 27H8.3" />
)}
</g>
</svg>
)}
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
</>
renderIcon={({ item }) => (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
)}
renderAction={() => (
<div className="DocSearch-Hit-action">

View file

@ -2,8 +2,9 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import type { JSX } from 'react';
import React, { memo, useMemo } from 'react';
import { AskAiSourcesPanel, type Exchange } from '../AskAiScreen';
import { type Exchange } from '../AskAiScreen';
import { FeedbackActions } from '../components/FeedbackActions';
import { SourcesPanel } from '../components/SourcesPanel';
import { ToolCall, type ToolCallTranslations } from '../components/ToolCall';
import { AlertIcon, LoadingIcon } from '../icons';
import { MemoizedMarkdown } from '../MemoizedMarkdown';
@ -137,7 +138,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
reasoningText = 'Reasoning...',
thinkingText = 'Thinking...',
searchingText = 'Searching...',
relatedSourcesText = 'Related sources',
relatedSourcesText,
stoppedStreamingText = 'You stopped this response',
preToolCallText = 'Searching...',
toolCallResultText = 'Searched for',
@ -174,8 +175,8 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{status === 'error' && streamError && isLastExchange && (
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
<AlertIcon />
<div className="DocSearch-AskAiScreen-Error" role="alert">
<AlertIcon aria-hidden="true" />
<div className="DocSearch-AskAiScreen-Error-Content">
<h4 className="DocSearch-AskAiScreen-Error-Title">{errorTitleText}</h4>
<MemoizedMarkdown
@ -250,8 +251,10 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
})}
{isThinking && isLastExchange && assistantParts.length === 0 && (
<div className="DocSearch-AskAiScreen-MessageContent-Reasoning">
<div className="DocSearch-AskAiScreen-MessageContent-Thinking" role="status">
<span className="shimmer">{thinkingText}</span>
<span className="DocSearch-AskAi-Thinking-Skeleton shimmer" />
<span className="DocSearch-AskAi-Thinking-Skeleton DocSearch-AskAi-Thinking-Skeleton--short shimmer" />
</div>
)}
</div>
@ -260,6 +263,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
</div>
<div className="DocSearch-AskAiScreen-Answer-Footer">
<SourcesPanel links={urlsToDisplay} titleText={relatedSourcesText} />
<FeedbackActions
isSidepanel={true}
id={messageId}
@ -271,10 +275,6 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
/>
</div>
</div>
{urlsToDisplay.length > 0 ? (
<AskAiSourcesPanel urlsToDisplay={urlsToDisplay} relatedSourcesText={relatedSourcesText} />
) : null}
</div>
);
},

View file

@ -300,7 +300,7 @@ describe('api', () => {
});
await act(async () => {
fireEvent.click(await screen.findByText(/Ask AI to help:/));
fireEvent.click(await screen.findByText('hello', { selector: '.DocSearch-Hit-AskAIButton-title-query' }));
});
expect(document.querySelector('.DocSearch-AskAiScreen')).toBeInTheDocument();

View file

@ -7,6 +7,7 @@ import '@testing-library/jest-dom/vitest';
import { AskAiSearchBox } from '../components/AskAiSearchBox';
import { KeywordSearchBox } from '../components/KeywordSearchBox';
import { SearchBoxForm } from '../components/ui/SearchBoxForm';
import { SOURCE_IDS } from '../utils';
function createState(overrides = {}): any {
return {
@ -66,14 +67,13 @@ describe('SearchBoxForm', () => {
vi.clearAllMocks();
});
it('renders the shared form, input, clear button, close button, and search label', () => {
it('renders the shared form, input, close button, and search label', () => {
renderSearchBoxForm({
state: createState({ query: 'docsearch' }),
});
expect(document.querySelector('.DocSearch-Form')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search docs')).toBeInTheDocument();
expect(document.querySelector('.DocSearch-Clear')).toHaveTextContent('Clear');
expect(screen.getByRole('button', { name: 'Close' })).toHaveAttribute('title', 'Close');
expect(document.querySelector('.DocSearch-MagnifierLabel')).toHaveTextContent('Search');
});
@ -156,7 +156,14 @@ describe('AskAiSearchBox', () => {
return render(
<AskAiSearchBox
{...createAutocomplete()}
state={createState({ query: 'follow up', collections: [null, null, { items: [{ objectID: '1' }] }] })}
state={createState({
query: 'follow up',
collections: [
null,
null,
{ source: { sourceId: SOURCE_IDS.recentConversations }, items: [{ objectID: '1' }] },
],
})}
autoFocus={false}
inputRef={React.createRef<HTMLInputElement>()}
isFromSelection={false}

View file

@ -9,6 +9,7 @@ import { KeywordStartScreen } from '../components/KeywordStartScreen';
import { RecentConversationsResults } from '../components/ui/RecentConversationsResults';
import { StoredSearchesSections } from '../components/ui/StoredSearchesSections';
import type { InternalDocSearchHit, StoredAskAiState } from '../types';
import { SOURCE_IDS } from '../utils';
afterEach(() => {
cleanup();
@ -109,8 +110,8 @@ describe('start screen components', () => {
const recentHit = createHit('recent', 'Recent result');
const favoriteHit = createHit('favorite', 'Favorite result');
const props = createProps([
createCollection('recentSearches', [recentHit]),
createCollection('favoriteSearches', [favoriteHit]),
createCollection(SOURCE_IDS.recentSearches, [recentHit]),
createCollection(SOURCE_IDS.favoriteSearches, [favoriteHit]),
]);
render(<StoredSearchesSections {...props} />);
@ -118,14 +119,14 @@ describe('start screen components', () => {
expect(screen.getByText('Recent result')).toBeInTheDocument();
expect(screen.getByText('Favorite result')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Save this search'));
fireEvent.click(screen.getByTitle('Pin this search'));
expect(props.favoriteSearches.add).toHaveBeenCalledWith(recentHit);
expect(props.recentSearches.remove).toHaveBeenCalledWith(recentHit);
fireEvent.click(screen.getByTitle('Remove this search from history'));
expect(props.recentSearches.remove).toHaveBeenCalledWith(recentHit);
fireEvent.click(screen.getByTitle('Remove this search from favorites'));
fireEvent.click(screen.getByTitle('Remove this saved search'));
expect(props.favoriteSearches.remove).toHaveBeenCalledWith(favoriteHit);
expect(props.refresh).toHaveBeenCalledTimes(3);
});
@ -133,9 +134,9 @@ describe('start screen components', () => {
it('renders recent conversations and removes them', () => {
const conversation = createHit('conversation', 'Conversation result') as InternalDocSearchHit & StoredAskAiState;
const props = createProps([
createCollection('recentSearches', []),
createCollection('favoriteSearches', []),
createCollection('recentConversations', [conversation]),
createCollection(SOURCE_IDS.recentSearches, []),
createCollection(SOURCE_IDS.favoriteSearches, []),
createCollection(SOURCE_IDS.recentConversations, [conversation]),
]);
render(<RecentConversationsResults {...props} />);
@ -149,9 +150,9 @@ describe('start screen components', () => {
it('keeps the keyword start screen scoped to stored searches', () => {
const props = createProps([
createCollection('recentSearches', []),
createCollection('favoriteSearches', []),
createCollection('recentConversations', [createHit('conversation', 'Conversation result')]),
createCollection(SOURCE_IDS.recentSearches, []),
createCollection(SOURCE_IDS.favoriteSearches, []),
createCollection(SOURCE_IDS.recentConversations, [createHit('conversation', 'Conversation result')]),
]);
render(
@ -169,9 +170,9 @@ describe('start screen components', () => {
it('composes stored searches and recent conversations for Ask AI', () => {
const props = createProps([
createCollection('recentSearches', []),
createCollection('favoriteSearches', []),
createCollection('recentConversations', [createHit('conversation', 'Conversation result')]),
createCollection(SOURCE_IDS.recentSearches, []),
createCollection(SOURCE_IDS.favoriteSearches, []),
createCollection(SOURCE_IDS.recentConversations, [createHit('conversation', 'Conversation result')]),
]);
render(

View file

@ -2,12 +2,12 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
import React, { type JSX, type RefObject } from 'react';
import { ConversationHistoryIcon, MoreVerticalIcon, NewConversationIcon, StopIcon } from '../icons';
import { BackIcon } from '../icons/BackIcon';
import { ConversationHistoryIcon, MoreVerticalIcon, NewConversationIcon, StopIcon, BackIcon } from '../icons';
import { Menu } from '../Menu';
import { ModalHeading } from '../ModalHeading';
import type { InternalDocSearchHit } from '../types';
import type { AIMessage, AskAiState } from '../types/AskiAi';
import { getCollection } from '../utils';
import { SearchBoxForm } from './ui/SearchBoxForm';
@ -77,14 +77,14 @@ export function AskAiSearchBox({
} = translations;
const hasRecentConversations = React.useMemo(() => {
const askAiSource = props.state.collections[2];
const askAiSource = getCollection(props.state, 'recentConversations');
if (!askAiSource) {
return false;
}
return askAiSource.items.length > 0;
}, [props.state.collections]);
}, [props.state]);
const baseInputProps = props.getInputProps({
inputElement: props.inputRef.current!,
@ -136,16 +136,23 @@ export function AskAiSearchBox({
const inputProps = {
enterKeyHint: props.isAskAiActive ? ('enter' as const) : ('search' as const),
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
let askAiHandled = false;
// block these up, down, enter listeners when Ask AI is active
if (props.isAskAiActive && blockedKeys.has(e.key)) {
// enter key asks another question
if (e.key === 'Enter' && !isAskAiStreaming && props.state.query) {
props.onAskAgain(props.state.query);
}
askAiHandled = true;
}
if (askAiHandled) {
e.preventDefault();
e.stopPropagation();
return;
}
origOnKeyDown?.(e);
},
onChange: (e: React.ChangeEvent<HTMLInputElement>): void => {

View file

@ -18,8 +18,8 @@ type AskAiStartScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, '
export function AskAiStartScreen({ translations = {}, ...props }: AskAiStartScreenProps): JSX.Element | null {
return (
<div className="DocSearch-Dropdown-Container">
<StoredSearchesSections {...props} translations={translations} />
<RecentConversationsResults {...props} translations={translations} />
<StoredSearchesSections {...props} translations={translations} />
</div>
);
}

View file

@ -35,6 +35,23 @@ export function KeywordSearchBox({ translations = {}, ...props }: KeywordSearchB
searchInputLabel = 'Search',
} = translations;
const actionsBeforeClose = (
<>
<button
className="DocSearch-Clear"
type="reset"
aria-label={clearButtonAriaLabel}
hidden={!props.state.query}
tabIndex={props.state.query ? 0 : -1}
aria-hidden={!props.state.query ? 'true' : 'false'}
>
{clearButtonTitle}
</button>
{props.state.query && <div className="DocSearch-Divider" />}
</>
);
return (
<SearchBoxForm
{...props}
@ -46,6 +63,7 @@ export function KeywordSearchBox({ translations = {}, ...props }: KeywordSearchB
inputProps={{
enterKeyHint: 'search',
}}
actionsBeforeClose={actionsBeforeClose}
/>
);
}

View file

@ -0,0 +1,90 @@
import React, { type JSX, useEffect } from 'react';
import { ContentIcon } from '../icons';
import type { ExtractedLink } from '../utils/ai';
import { Popover } from './ui/Popover';
interface SourcesProps {
links: ExtractedLink[];
titleText?: string;
}
export function SourcesPanel({ links, titleText = 'Sources' }: SourcesProps): JSX.Element | null {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const [boundary, setBoundary] = React.useState<HTMLElement | null>(null);
useEffect(() => {
if (!open) {
return undefined;
}
const trigger = triggerRef.current;
const scrollContainer =
trigger?.closest<HTMLElement>('.DocSearch-Sidepanel-Content') ??
trigger?.closest<HTMLElement>('.DocSearch-Dropdown') ??
null;
setBoundary(scrollContainer);
if (!scrollContainer) {
return undefined;
}
const handleScroll = (): void => {
if (!triggerRef.current) {
return;
}
const triggerRect = triggerRef.current.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
const isOutOfView = triggerRect.bottom <= containerRect.top || triggerRect.top >= containerRect.bottom;
if (isOutOfView) {
setOpen(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [open]);
if (links.length === 0) {
return null;
}
return (
<Popover open={open} modal="trap-focus" onOpenChange={setOpen}>
<Popover.Trigger ref={triggerRef} className="DocSearch-AskAiScreen-Sources-Action">
<span className="DocSearch-AskAiScreen-Sources-Action-icon">
<ContentIcon />
</span>
<span className="DocSearch-AskAiScreen-Sources-Action-text">{links.length} sources</span>
</Popover.Trigger>
<Popover.Popup
container={boundary}
sideOffset={10}
align="start"
alignOffset={-16}
collisionBoundary={boundary ?? 'clipping-ancestors'}
>
<Popover.Title>{titleText}</Popover.Title>
<ul className="DocSearch-AskAiScreen-Sources">
{links.map((l) => (
<li key={l.url} className="DocSearch-AskAiScreen-Sources-source">
<a href={l.url} target="_blank" rel="noopener noreferrer">
<ContentIcon />
<span className="DocSearch-AskAiScreen-Sources-source-title">{l.title || l.url}</span>
</a>
</li>
))}
</ul>
</Popover.Popup>
</Popover>
);
}

View file

@ -72,7 +72,6 @@ function SearchTool({ part, translations, onSearchQueryClick }: SearchToolProps)
);
case 'output-available': {
const query = part.type === 'tool-searchIndex' ? part.output.query : part.input.query;
const numberOfHits = part.output.hits?.length ?? 0;
return (
<ToolState icon={<SearchIcon />} variant="Result">
@ -96,8 +95,7 @@ function SearchTool({ part, translations, onSearchQueryClick }: SearchToolProps)
</span>
) : (
<span className="DocSearch-AskAiScreen-MessageContent-Tool-Query"> &quot;{query || ''}&quot;</span>
)}{' '}
found {numberOfHits} results
)}
</span>
</ToolState>
);

View file

@ -3,9 +3,9 @@ import '@testing-library/jest-dom/vitest';
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FeedbackActions, type FeedbackActionsTranslations } from '../components/FeedbackActions';
import type { StoredSearchPlugin } from '../stored-searches';
import type { StoredAskAiMessage, StoredAskAiState } from '../types';
import type { StoredSearchPlugin } from '../../stored-searches';
import type { StoredAskAiMessage, StoredAskAiState } from '../../types';
import { FeedbackActions, type FeedbackActionsTranslations } from '../FeedbackActions';
const TRANSLATIONS: FeedbackActionsTranslations = {
likeButtonTitle: 'Like',

View file

@ -1,10 +1,10 @@
import { render, screen, within } from '@testing-library/react';
import { cleanup, render, screen, within } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { ToolCall, type ToolCallTranslations } from '../components/ToolCall';
import type { AIToolPart, MemoryToolPart, ToolCalls } from '../types/AskiAi';
import type { AIToolPart, MemoryToolPart, ToolCalls } from '../../types/AskiAi';
import { ToolCall, type ToolCallTranslations } from '../ToolCall';
const TRANSLATIONS: ToolCallTranslations = {
preToolCallText: 'Searching for',
@ -15,7 +15,11 @@ const TRANSLATIONS: ToolCallTranslations = {
};
describe('ToolCall', () => {
describe('number of hits rendering', () => {
afterEach(() => {
cleanup();
});
describe('search tools', () => {
it.each([
{
description: 'tool-searchIndex with hits array',
@ -26,7 +30,6 @@ describe('ToolCall', () => {
input: { query: 'test' },
output: { query: 'test', hits: [{}, {}, {}] },
},
expectedHits: 3,
},
{
description: 'tool-searchIndex with empty hits',
@ -37,7 +40,6 @@ describe('ToolCall', () => {
input: { query: 'test' },
output: { query: 'test', hits: [] },
},
expectedHits: 0,
},
{
description: 'tool-algolia_search_index with nbHits',
@ -53,7 +55,6 @@ describe('ToolCall', () => {
},
output: { hits: [{}, {}] },
},
expectedHits: 2,
},
{
description: 'tool-algolia_search_index_custom with results array',
@ -64,16 +65,15 @@ describe('ToolCall', () => {
input: { query: 'test' },
output: { hits: [{}] },
},
expectedHits: 1,
},
] satisfies Array<{
description: string;
part: AIToolPart;
expectedHits: number;
}>)('displays $expectedHits results for $description', ({ part, expectedHits }) => {
render(<ToolCall part={part} translations={TRANSLATIONS} tools={{}} />);
}>)('renders the completed search tool result for $description', ({ part }) => {
const { container } = render(<ToolCall part={part} translations={TRANSLATIONS} tools={{}} />);
expect(screen.getByText(`found ${expectedHits} results`, { exact: false })).toBeInTheDocument();
expect(within(container).getByText('Searched', { exact: false })).toBeInTheDocument();
expect(within(container).getByText(/"test"/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,62 @@
import { Popover as PopoverPrimitive } from '@base-ui/react/popover';
import React from 'react';
import { CloseIcon } from '../../icons';
export function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root {...props} />;
}
function PopoverTrigger({ className, ...props }: PopoverPrimitive.Trigger.Props) {
const cn = `DocSearch-Popover-Trigger${className ? ` ${className}` : ''}`;
return <PopoverPrimitive.Trigger className={cn} {...props} />;
}
Popover.Trigger = PopoverTrigger;
type PopoverPopupProps = Pick<
PopoverPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'collisionBoundary' | 'side' | 'sideOffset'
> &
Pick<PopoverPrimitive.Portal.Props, 'container'> &
PopoverPrimitive.Popup.Props;
function PopoverPopup({
align = 'center',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
collisionBoundary = 'clipping-ancestors',
container,
children,
...props
}: PopoverPopupProps) {
return (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Positioner
className="DocSearch-Popover-Positioner"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
collisionBoundary={collisionBoundary}
>
<PopoverPrimitive.Popup className="DocSearch-Popover-Popup" {...props}>
<PopoverPrimitive.Arrow className="DocSearch-Popover-Arrow" />
<PopoverPrimitive.Close className="DocSearch-Popover-Close">
<CloseIcon />
</PopoverPrimitive.Close>
{children}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
Popover.Popup = PopoverPopup;
function PopoverTitle({ ...props }: PopoverPrimitive.Title.Props) {
return <PopoverPrimitive.Title className="DocSearch-Popover-Title" {...props} />;
}
Popover.Title = PopoverTitle;

View file

@ -1,9 +1,10 @@
import React, { type JSX } from 'react';
import type { AskAiScreenStateProps } from '../../AskAiScreenState';
import { CloseIcon, SparklesIcon } from '../../icons';
import { CloseIcon, MessageIcon } from '../../icons';
import { Results } from '../../Results';
import type { InternalDocSearchHit } from '../../types';
import { getCollection } from '../../utils';
export type RecentConversationsResultsTranslations = Partial<{
recentConversationsTitle: string;
@ -17,20 +18,25 @@ type RecentConversationsResultsProps = Omit<AskAiScreenStateProps<InternalDocSea
export function RecentConversationsResults({
translations = {},
...props
}: RecentConversationsResultsProps): JSX.Element {
}: RecentConversationsResultsProps): JSX.Element | null {
const {
recentConversationsTitle = 'Recent conversations',
recentConversationsTitle = 'Resume AI conversations',
removeRecentConversationButtonTitle = 'Remove this conversation from history',
} = translations;
const recentConversations = getCollection(props.state, 'recentConversations');
if (!recentConversations) {
return null;
}
return (
<Results
{...props}
title={recentConversationsTitle}
collection={props.state.collections[2]}
collection={recentConversations}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<SparklesIcon />
<div className="DocSearch-Hit-icon DocSearch-Hit-icon--small">
<MessageIcon />
</div>
)}
renderAction={({ item }) => (

View file

@ -100,19 +100,6 @@ export function SearchBoxForm({
/>
<div className="DocSearch-Actions">
<button
className="DocSearch-Clear"
type="reset"
aria-label={clearButtonAriaLabel}
hidden={!state.query}
tabIndex={state.query ? 0 : -1}
aria-hidden={!state.query ? 'true' : 'false'}
>
{clearButtonTitle}
</button>
{state.query && <div className="DocSearch-Divider" />}
{actionsBeforeClose}
<button

View file

@ -1,9 +1,10 @@
import React, { type JSX } from 'react';
import { CloseIcon, RecentIcon, StarIcon } from '../../icons';
import { CloseIcon, PinIcon, RecentIcon, SourceIcon } from '../../icons';
import { Results } from '../../Results';
import type { ScreenStateProps } from '../../ScreenState';
import type { InternalDocSearchHit } from '../../types';
import { getCollection } from '../../utils';
export type StoredSearchesSectionsTranslations = Partial<{
recentSearchesTitle: string;
@ -20,89 +21,96 @@ type StoredSearchesSectionsProps = Omit<ScreenStateProps<InternalDocSearchHit>,
export function StoredSearchesSections({ translations = {}, ...props }: StoredSearchesSectionsProps): JSX.Element {
const {
recentSearchesTitle = 'Recent',
saveRecentSearchButtonTitle = 'Save this search',
recentSearchesTitle = 'Recently viewed docs',
saveRecentSearchButtonTitle = 'Pin this search',
removeRecentSearchButtonTitle = 'Remove this search from history',
favoriteSearchesTitle = 'Favorite',
removeFavoriteSearchButtonTitle = 'Remove this search from favorites',
favoriteSearchesTitle = 'Pinned',
removeFavoriteSearchButtonTitle = 'Remove this saved search',
} = translations;
const recentSearchesCollection = props.state.collections[0];
const favoriteSearches = getCollection(props.state, 'favoriteSearches');
const recentSearchesCollection = getCollection(props.state, 'recentSearches');
return (
<>
<Results
{...props}
title={recentSearchesTitle}
collection={recentSearchesCollection}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<RecentIcon />
</div>
)}
renderAction={({ item }) => (
<>
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={saveRecentSearchButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.favoriteSearches.add(item);
props.recentSearches.remove(item);
props.refresh();
}}
>
<StarIcon />
</button>
{favoriteSearches && (
<Results
{...props}
title={favoriteSearchesTitle}
collection={favoriteSearches}
sourceIcon={<PinIcon />}
renderIcon={({ item }) => (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
)}
renderAction={({ item }) => (
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={removeRecentSearchButtonTitle}
title={removeFavoriteSearchButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.recentSearches.remove(item);
props.favoriteSearches.remove(item);
props.refresh();
}}
>
<CloseIcon />
</button>
</div>
</>
)}
/>
)}
/>
)}
<Results
{...props}
title={favoriteSearchesTitle}
collection={props.state.collections[1]}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<StarIcon />
</div>
)}
renderAction={({ item }) => (
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={removeFavoriteSearchButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.favoriteSearches.remove(item);
props.refresh();
}}
>
<CloseIcon />
</button>
</div>
)}
/>
{recentSearchesCollection && (
<Results
{...props}
title={recentSearchesTitle}
collection={recentSearchesCollection}
sourceIcon={<RecentIcon />}
renderIcon={({ item }) => (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
)}
renderAction={({ item }) => (
<>
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button DocSearch-Hit-action-button--pin"
title={saveRecentSearchButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.favoriteSearches.add(item);
props.recentSearches.remove(item);
props.refresh();
}}
>
<PinIcon />
</button>
</div>
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={removeRecentSearchButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.recentSearches.remove(item);
props.refresh();
}}
>
<CloseIcon />
</button>
</div>
</>
)}
/>
)}
</>
);
}

View file

@ -4,10 +4,10 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { MAX_QUERY_SIZE } from '../constants';
import { useInitialModalQuery } from '../hooks/useInitialModalQuery';
import { useModalRefs } from '../hooks/useModalRefs';
import { useRefreshOnInitialQuery } from '../hooks/useRefreshOnInitialQuery';
import { MAX_QUERY_SIZE } from '../../constants';
import { useInitialModalQuery } from '../useInitialModalQuery';
import { useModalRefs } from '../useModalRefs';
import { useRefreshOnInitialQuery } from '../useRefreshOnInitialQuery';
describe('modal hooks', () => {
afterEach(() => {

View file

@ -0,0 +1,136 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useRelativeFormattedDate } from '../useRelativeFormattedDate';
const NOW = new Date('2024-01-15T12:00:00.000Z').getTime();
describe('useRelativeFormattedDate', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('returns days when elapsed time is at least one day', () => {
const start = new Date(NOW - 3 * 24 * 60 * 60 * 1000);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('3 days ago');
});
it('returns hours when elapsed time is at least one hour but less than a day', () => {
const start = new Date(NOW - 5 * 60 * 60 * 1000);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('5 hours ago');
});
it('returns minutes when elapsed time is at least one minute but less than an hour', () => {
const start = new Date(NOW - 42 * 60 * 1000);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('42 minutes ago');
});
it('returns seconds when elapsed time is at least one second but less than a minute', () => {
const start = new Date(NOW - 30 * 1000);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('30 seconds ago');
});
it('falls back to seconds for sub-second differences', () => {
const start = new Date(NOW - 500);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('0 seconds ago');
});
it('returns zero seconds when start and end are equal', () => {
const start = new Date(NOW);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('0 seconds ago');
});
it('returns null when start is null', () => {
const { result } = renderHook(() => useRelativeFormattedDate(null));
expect(result.current).toBeNull();
});
it('floors the elapsed value to the unit', () => {
// 2 hours and 59 minutes -> 2 hours
const start = new Date(NOW - (2 * 60 * 60 * 1000 + 59 * 60 * 1000));
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('2 hours ago');
});
it('uses the largest matching unit at boundaries', () => {
// Exactly one day
const start = new Date(NOW - 24 * 60 * 60 * 1000);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('1 day ago');
});
it('uses the absolute value when end is before start (future dates)', () => {
const start = new Date(NOW + 3 * 60 * 60 * 1000);
const { result } = renderHook(() => useRelativeFormattedDate(start));
expect(result.current).toEqual('3 hours ago');
});
it('respects an explicit end value over Date.now()', () => {
const start = new Date('2024-01-01T00:00:00.000Z');
const end = new Date('2024-01-08T00:00:00.000Z').getTime();
const { result } = renderHook(() => useRelativeFormattedDate(start, end));
expect(result.current).toEqual('7 days ago');
});
it('returns a stable reference when inputs do not change', () => {
const start = new Date(NOW - 60 * 60 * 1000);
const end = NOW;
const { result, rerender } = renderHook(() => useRelativeFormattedDate(start, end));
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
it('does not recompute when inputs change', () => {
const end = NOW;
const { result, rerender } = renderHook(({ start }) => useRelativeFormattedDate(start, end), {
initialProps: { start: new Date(NOW - 60 * 60 * 1000) },
});
const first = result.current;
expect(result.current).toEqual('1 hour ago');
rerender({ start: new Date(NOW - 2 * 24 * 60 * 60 * 1000) });
expect(result.current).toBe(first);
expect(result.current).toEqual('1 hour ago');
});
it('does not recompute when Date.now changes', () => {
const start = new Date(NOW - 60 * 60 * 1000);
const { result, rerender } = renderHook(() => useRelativeFormattedDate(start));
const first = result.current;
vi.setSystemTime(NOW + 2 * 24 * 60 * 60 * 1000);
rerender();
expect(result.current).toBe(first);
expect(result.current).toEqual('1 hour ago');
});
});

View file

@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { ACTION_KEY_APPLE, ACTION_KEY_DEFAULT, isAppleDevice } from '../utils';
export function usePlatformKeys() {
const [key, setKey] = useState<typeof ACTION_KEY_APPLE | typeof ACTION_KEY_DEFAULT | null>(null);
useEffect(() => {
if (typeof navigator !== 'undefined') {
isAppleDevice() ? setKey(ACTION_KEY_APPLE) : setKey(ACTION_KEY_DEFAULT);
}
}, []);
const [actionKeyReactsTo, actionKeyAltText, actionKeyLabel] =
key === ACTION_KEY_DEFAULT ? ([ACTION_KEY_DEFAULT, 'Control', 'Ctrl'] as const) : (['Meta', 'Meta', '⌘'] as const);
return {
actionKeyReactsTo,
actionKeyAltText,
actionKeyLabel,
key,
};
}

View file

@ -0,0 +1,33 @@
import { useMemo, useState } from 'react';
const MS_IN_SECOND = 1000;
const MS_IN_MINUTE = MS_IN_SECOND * 60;
const MS_IN_HOUR = MS_IN_MINUTE * 60;
const MS_IN_DAY = MS_IN_HOUR * 24;
const UNITS: Array<[number, Intl.RelativeTimeFormatUnit]> = [
[MS_IN_DAY, 'day'],
[MS_IN_HOUR, 'hour'],
[MS_IN_MINUTE, 'minute'],
[MS_IN_SECOND, 'second'],
];
export function useRelativeFormattedDate(start: Date | null, end = Date.now()): string | null {
const [diff] = useState<[number, Intl.RelativeTimeFormatUnit]>(() => {
const elapsed = Math.abs(Number(end) - Number(start));
const [ms, unit] = UNITS.find(([threshold]) => elapsed >= threshold) ?? UNITS[UNITS.length - 1];
return [Math.floor(elapsed / ms), unit];
});
const formattedDate = useMemo(() => {
const locale = typeof navigator !== 'undefined' ? navigator.language : undefined;
const [elapsedTime, elapsedUnit] = diff;
return new Intl.RelativeTimeFormat(locale, {
style: 'long',
}).format(elapsedTime * -1, elapsedUnit);
}, [diff]);
return start ? formattedDate : null;
}

View file

@ -1,6 +1,6 @@
import React, { type JSX } from 'react';
export function AlertIcon(): JSX.Element {
export function AlertIcon(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg
width="16"
@ -12,7 +12,7 @@ export function AlertIcon(): JSX.Element {
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="lucide lucide-triangle-alert-icon lucide-triangle-alert"
{...props}
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />

View file

@ -0,0 +1,19 @@
import React, { type JSX } from 'react';
export function MessageIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
import React, { type JSX } from 'react';
export function PinIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>
);
}

View file

@ -53,16 +53,23 @@ function AnchorIcon(): JSX.Element {
);
}
function ContentIcon(): JSX.Element {
export function ContentIcon(): JSX.Element {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path
d="M17 5H3h14zm0 5H3h14zm0 5H3h14z"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinejoin="round"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
}

View file

@ -20,3 +20,6 @@ export * from './FolderIcon';
export * from './EditIcon';
export * from './ToolIcon';
export * from './MemoryIcon';
export * from './MessageIcon';
export * from './PinIcon';
export * from './BackIcon';

View file

@ -15,10 +15,10 @@ import type {
import { sanitizeUserInput } from './sanitize';
type ExtractedLink = {
export interface ExtractedLink {
url: string;
title?: string;
};
}
// utility to extract links (markdown and bare urls) from a string
export function extractLinksFromMessage(message: AIMessage | null): ExtractedLink[] {
@ -95,7 +95,7 @@ export const buildDummyAskAiHit = (query: string, messages: AIMessage[]): Stored
hierarchy: {
lvl0: 'askAI',
lvl1: sanitizedText, // use first message as hit name (sanitized to prevent XSS)
lvl2: null,
lvl2: new Date().toISOString(),
lvl3: null,
lvl4: null,
lvl5: null,

View file

@ -0,0 +1,21 @@
import type { AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
export const SOURCE_IDS = {
askAI: 'askAI',
favoriteSearches: 'favoriteSearches',
recentConversations: 'recentConversations',
recentSearches: 'recentSearches',
} as const;
export type KnownSourceId = (typeof SOURCE_IDS)[keyof typeof SOURCE_IDS];
type Collection<TItem extends BaseItem> = AutocompleteState<TItem>['collections'][number];
export function getCollection<TItem extends BaseItem>(
state: { collections: Array<Collection<TItem> | null | undefined> },
sourceId: KnownSourceId,
): Collection<TItem> | undefined {
return state.collections.find(
(collection): collection is Collection<TItem> => collection?.source.sourceId === sourceId,
);
}

View file

@ -3,6 +3,10 @@ import type { AutocompleteSource } from '@algolia/autocomplete-core';
import type { InternalDocSearchHit } from '../types';
import type { AIMessage } from '../types/AskiAi';
import { SOURCE_IDS } from './collections';
const MAX_RECENT_CONVERSATIONS_DISPLAYED = 3;
export function buildRecentConversationSources({
conversations,
disableUserPersonalization,
@ -16,12 +20,15 @@ export function buildRecentConversationSources({
}): Array<AutocompleteSource<InternalDocSearchHit & { messages?: AIMessage[] }>> {
return [
{
sourceId: 'recentConversations',
sourceId: SOURCE_IDS.recentConversations,
getItems(): InternalDocSearchHit[] {
if (disableUserPersonalization) {
return [];
}
return conversations.getAll() as unknown as InternalDocSearchHit[];
return (conversations.getAll() as unknown as InternalDocSearchHit[]).slice(
0,
MAX_RECENT_CONVERSATIONS_DISPLAYED,
);
},
onSelect({ item }): void {
if (item.messages) {
@ -62,7 +69,7 @@ export function buildAskAiActionSources({
return [
{
sourceId: 'askAI',
sourceId: SOURCE_IDS.askAI,
getItems(): InternalDocSearchHit[] {
return [
{

View file

@ -6,6 +6,7 @@ import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
import type { DocSearchHit, DocSearchState, InternalDocSearchHit } from '../types';
import type { useSearchClient } from '../useSearchClient';
import { SOURCE_IDS } from './collections';
import { groupBy } from './groupBy';
import { identity } from './identity';
import { isModifierEvent } from './isModifierEvent';
@ -36,22 +37,7 @@ export function buildNoQuerySources({
return [
{
sourceId: 'recentSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return recentSearches.getAll() as InternalDocSearchHit[];
},
},
{
sourceId: 'favoriteSearches',
sourceId: SOURCE_IDS.favoriteSearches,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
@ -65,6 +51,21 @@ export function buildNoQuerySources({
return favoriteSearches.getAll() as InternalDocSearchHit[];
},
},
{
sourceId: SOURCE_IDS.recentSearches,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return recentSearches.getAll() as InternalDocSearchHit[];
},
},
];
}
@ -173,8 +174,10 @@ export async function buildQuerySources({
};
}
return Object.values<DocSearchHit[]>(sources).map((items, index) => ({
sourceId: `hits_${result.index}_${index}`,
const items = Object.values(sources).flat();
return {
sourceId: `hits_${result.index}`,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
@ -184,30 +187,32 @@ export async function buildQuerySources({
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
getItems() {
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
.map((groupedHits) =>
groupedHits.map((item) => {
let parent: InternalDocSearchHit | null = null;
groupedHits
.map((item) => {
let parent: InternalDocSearchHit | null = null;
const potentialParent = groupedHits.find(
(siblingItem) => siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1,
) as InternalDocSearchHit | undefined;
const potentialParent = groupedHits.find(
(siblingItem) => siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1,
) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
}),
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
})
.flat(),
)
.flat();
},
}));
};
});
} catch (error) {
if ((error as Error).name === 'RetryError') {

View file

@ -1,3 +1,4 @@
export * from './collections';
export * from './groupBy';
export * from './identity';
export * from './isModifierEvent';