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:
parent
34ea03a2eb
commit
0da291bb95
56 changed files with 1418 additions and 465 deletions
27
bun.lock
27
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 } }) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
123
examples/demo-react/src/icons.tsx
Normal file
123
examples/demo-react/src/icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ export function DocSearchAskAiModal({
|
|||
onAskAiToggle,
|
||||
})
|
||||
: [];
|
||||
return [...noQuerySources, ...recentConversationSource];
|
||||
return [...recentConversationSource, ...noQuerySources];
|
||||
}
|
||||
|
||||
const querySourcesState: BuildQuerySourcesState = {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
90
packages/docsearch-react/src/components/SourcesPanel.tsx
Normal file
90
packages/docsearch-react/src/components/SourcesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"> "{query || ''}"</span>
|
||||
)}{' '}
|
||||
found {numberOfHits} results
|
||||
)}
|
||||
</span>
|
||||
</ToolState>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
62
packages/docsearch-react/src/components/ui/Popover.tsx
Normal file
62
packages/docsearch-react/src/components/ui/Popover.tsx
Normal 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;
|
||||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
23
packages/docsearch-react/src/hooks/usePlatformKeys.ts
Normal file
23
packages/docsearch-react/src/hooks/usePlatformKeys.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
19
packages/docsearch-react/src/icons/MessageIcon.tsx
Normal file
19
packages/docsearch-react/src/icons/MessageIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/docsearch-react/src/icons/PinIcon.tsx
Normal file
20
packages/docsearch-react/src/icons/PinIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
21
packages/docsearch-react/src/utils/collections.ts
Normal file
21
packages/docsearch-react/src/utils/collections.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './collections';
|
||||
export * from './groupBy';
|
||||
export * from './identity';
|
||||
export * from './isModifierEvent';
|
||||
|
|
|
|||
Loading…
Reference in a new issue