From 0da291bb951d98fd7857c80cacb453f248d130d0 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8bittitan@gmail.com> Date: Thu, 11 Jun 2026 12:51:22 -0400 Subject: [PATCH] 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 --- bun.lock | 27 +- bundlesize.config.json | 2 +- e2e/search.spec.ts | 21 +- examples/demo-react/src/App.css | 94 +++- examples/demo-react/src/App.tsx | 37 +- .../demo-react/src/examples/basic-askai.tsx | 5 +- examples/demo-react/src/examples/basic.tsx | 5 +- .../demo-react/src/examples/composable.tsx | 6 +- examples/demo-react/src/examples/default.tsx | 6 +- .../src/examples/dynamic-import-modal.tsx | 10 +- examples/demo-react/src/examples/hybrid.tsx | 6 +- .../demo-react/src/examples/multi-index.tsx | 5 +- .../demo-react/src/examples/sidepanel.tsx | 6 +- .../src/examples/w-hit-component.tsx | 5 +- .../src/examples/w-hit-transformItems.tsx | 5 +- .../src/examples/w-results-footer.tsx | 5 +- examples/demo-react/src/icons.tsx | 123 +++++ packages/docsearch-css/src/_variables.css | 23 +- packages/docsearch-css/src/modal.css | 447 +++++++++++++++--- packages/docsearch-css/src/sidepanel.css | 7 +- packages/docsearch-react/package.json | 1 + packages/docsearch-react/src/AskAiScreen.tsx | 83 +--- .../src/ConversationHistoryScreen.tsx | 12 +- .../src/DocSearchAskAiModal.tsx | 2 +- .../docsearch-react/src/DocSearchButton.tsx | 14 +- packages/docsearch-react/src/Results.tsx | 55 ++- .../docsearch-react/src/ResultsScreen.tsx | 36 +- .../src/Sidepanel/ConversationScreen.tsx | 18 +- .../src/__tests__/api.test.tsx | 2 +- .../src/__tests__/searchbox.test.tsx | 13 +- .../src/__tests__/startscreen.test.tsx | 27 +- .../src/components/AskAiSearchBox.tsx | 15 +- .../src/components/AskAiStartScreen.tsx | 2 +- .../src/components/KeywordSearchBox.tsx | 18 + .../src/components/SourcesPanel.tsx | 90 ++++ .../src/components/ToolCall.tsx | 4 +- .../__tests__/FeedbackActions.test.tsx | 6 +- .../__tests__/ToolCall.test.tsx | 26 +- .../src/components/ui/Popover.tsx | 62 +++ .../ui/RecentConversationsResults.tsx | 18 +- .../src/components/ui/SearchBoxForm.tsx | 13 - .../components/ui/StoredSearchesSections.tsx | 136 +++--- .../src/{ => hooks}/__tests__/modal.test.tsx | 8 +- .../useRelativeFormattedDate.test.tsx | 136 ++++++ .../src/hooks/usePlatformKeys.ts | 23 + .../src/hooks/useRelativeFormattedDate.ts | 33 ++ .../docsearch-react/src/icons/AlertIcon.tsx | 4 +- .../docsearch-react/src/icons/MessageIcon.tsx | 19 + .../docsearch-react/src/icons/PinIcon.tsx | 20 + .../docsearch-react/src/icons/SourceIcon.tsx | 25 +- packages/docsearch-react/src/icons/index.ts | 3 + packages/docsearch-react/src/utils/ai.ts | 6 +- .../docsearch-react/src/utils/collections.ts | 21 + .../src/utils/createAskAiSources.ts | 13 +- .../src/utils/createDocSearchSources.ts | 73 +-- packages/docsearch-react/src/utils/index.ts | 1 + 56 files changed, 1418 insertions(+), 465 deletions(-) create mode 100644 examples/demo-react/src/icons.tsx create mode 100644 packages/docsearch-react/src/components/SourcesPanel.tsx rename packages/docsearch-react/src/{ => components}/__tests__/FeedbackActions.test.tsx (97%) rename packages/docsearch-react/src/{ => components}/__tests__/ToolCall.test.tsx (92%) create mode 100644 packages/docsearch-react/src/components/ui/Popover.tsx rename packages/docsearch-react/src/{ => hooks}/__tests__/modal.test.tsx (93%) create mode 100644 packages/docsearch-react/src/hooks/__tests__/useRelativeFormattedDate.test.tsx create mode 100644 packages/docsearch-react/src/hooks/usePlatformKeys.ts create mode 100644 packages/docsearch-react/src/hooks/useRelativeFormattedDate.ts create mode 100644 packages/docsearch-react/src/icons/MessageIcon.tsx create mode 100644 packages/docsearch-react/src/icons/PinIcon.tsx create mode 100644 packages/docsearch-react/src/utils/collections.ts diff --git a/bun.lock b/bun.lock index 58610a41..450536e4 100644 --- a/bun.lock +++ b/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=="], diff --git a/bundlesize.config.json b/bundlesize.config.json index eca76886..75d9d09b 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -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", diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index 214cda7d..c2fb59ce 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -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(); }); }); diff --git a/examples/demo-react/src/App.css b/examples/demo-react/src/App.css index 4970de0d..be96445d 100644 --- a/examples/demo-react/src/App.css +++ b/examples/demo-react/src/App.css @@ -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 */ diff --git a/examples/demo-react/src/App.tsx b/examples/demo-react/src/App.tsx index 5586c4f3..435df62a 100644 --- a/examples/demo-react/src/App.tsx +++ b/examples/demo-react/src/App.tsx @@ -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('light'); + + const toggleTheme = (): void => { + setTheme((currentTheme) => (currentTheme === 'light' ? 'dark' : 'light')); + }; + return ( -
+

DocSearch v{version}

Experience the power of intelligent documentation search

+

default

- +

basic search functionality

- +

search with ask ai integration

- +

custom hit rendering

- +

transform items before rendering

- +

multi index search

- +

composable

- +

dynamically imported modal

- +

results footer component

- +

sidepanel hybrid

- +
@@ -108,6 +120,7 @@ function App(): JSX.Element { panel={{ suggestedQuestions: true, }} + theme={theme} />
); diff --git a/examples/demo-react/src/examples/basic-askai.tsx b/examples/demo-react/src/examples/basic-askai.tsx index e62e6439..e96a77df 100644 --- a/examples/demo-react/src/examples/basic-askai.tsx +++ b/examples/demo-react/src/examples/basic-askai.tsx @@ -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 ( ); } diff --git a/examples/demo-react/src/examples/composable.tsx b/examples/demo-react/src/examples/composable.tsx index 61361406..419fb886 100644 --- a/examples/demo-react/src/examples/composable.tsx +++ b/examples/demo-react/src/examples/composable.tsx @@ -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 ( - + ; +import type { DemoTheme } from '../App'; + +export default function DefaultExperience({ theme }: { theme: DemoTheme }): JSX.Element { + return ; } diff --git a/examples/demo-react/src/examples/dynamic-import-modal.tsx b/examples/demo-react/src/examples/dynamic-import-modal.tsx index d63f247c..466e1ca1 100644 --- a/examples/demo-react/src/examples/dynamic-import-modal.tsx +++ b/examples/demo-react/src/examples/dynamic-import-modal.tsx @@ -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 { @@ -17,7 +19,7 @@ function importDocSearchModalIfNeeded(): Promise { }); } -function DocSearch(): JSX.Element { +function DocSearch({ theme }: { theme: DemoTheme }): JSX.Element { const [isOpen, setIsOpen] = useState(false); const [initialQuery, setInitialQuery] = useState(undefined); const [isAskAiActive, setIsAskAiActive] = useState(false); @@ -76,6 +78,7 @@ function DocSearch(): JSX.Element { , @@ -105,6 +109,6 @@ function DocSearch(): JSX.Element { ); } -export default function DynamicImportModal(): JSX.Element { - return ; +export default function DynamicImportModal({ theme }: { theme: DemoTheme }): JSX.Element { + return ; } diff --git a/examples/demo-react/src/examples/hybrid.tsx b/examples/demo-react/src/examples/hybrid.tsx index 979aa1d4..8609416e 100644 --- a/examples/demo-react/src/examples/hybrid.tsx +++ b/examples/demo-react/src/examples/hybrid.tsx @@ -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 ( - + ); } diff --git a/examples/demo-react/src/examples/sidepanel.tsx b/examples/demo-react/src/examples/sidepanel.tsx index dbcf0930..31b6a102 100644 --- a/examples/demo-react/src/examples/sidepanel.tsx +++ b/examples/demo-react/src/examples/sidepanel.tsx @@ -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 ( - + ); } diff --git a/examples/demo-react/src/examples/w-hit-transformItems.tsx b/examples/demo-react/src/examples/w-hit-transformItems.tsx index 42b8b8c2..b78ea419 100644 --- a/examples/demo-react/src/examples/w-hit-transformItems.tsx +++ b/examples/demo-react/src/examples/w-hit-transformItems.tsx @@ -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 ( ); } diff --git a/examples/demo-react/src/examples/w-results-footer.tsx b/examples/demo-react/src/examples/w-results-footer.tsx index 0ee895cb..279caceb 100644 --- a/examples/demo-react/src/examples/w-results-footer.tsx +++ b/examples/demo-react/src/examples/w-results-footer.tsx @@ -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 }): JSX.Element { // Using JSX templates return ( @@ -15,7 +17,7 @@ function ResultsFooterComponent({ state }: { state: AutocompleteState ); } diff --git a/examples/demo-react/src/icons.tsx b/examples/demo-react/src/icons.tsx new file mode 100644 index 00000000..ec48d9ae --- /dev/null +++ b/examples/demo-react/src/icons.tsx @@ -0,0 +1,123 @@ +/* eslint-disable react/react-in-jsx-scope */ + +export function MoonIcon() { + return ( + + + + + ); +} + +export function SunIcon() { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/docsearch-css/src/_variables.css b/packages/docsearch-css/src/_variables.css index fd43cbba..ccbde993 100644 --- a/packages/docsearch-css/src/_variables.css +++ b/packages/docsearch-css/src/_variables.css @@ -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); } diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 4ae526f7..392e1476 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -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; diff --git a/packages/docsearch-css/src/sidepanel.css b/packages/docsearch-css/src/sidepanel.css index 89d8aa95..3f55c96d 100644 --- a/packages/docsearch-css/src/sidepanel.css +++ b/packages/docsearch-css/src/sidepanel.css @@ -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; diff --git a/packages/docsearch-react/package.json b/packages/docsearch-react/package.json index b85889ad..296a0d43 100644 --- a/packages/docsearch-react/package.json +++ b/packages/docsearch-react/package.json @@ -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" }, diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index a55d004f..08f89752 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -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

{disclaimerText}

; +function AskAiScreenDisclaimer({ disclaimerText }: AskAiScreenHeaderProps): JSX.Element { + return ( +

+ {disclaimerText} +

+ ); } 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({
{loadingStatus === 'error' && askAiError && isLastExchange && !isThreadDepth && ( -
- +
+
- ); -} - -interface AskAiSourcesPanelProps { - urlsToDisplay: Array<{ url: string; title?: string }>; - relatedSourcesText?: string; -} - -export function AskAiSourcesPanel({ urlsToDisplay, relatedSourcesText }: AskAiSourcesPanelProps): JSX.Element { - return ( -
-

{relatedSourcesText || 'Related sources'}

-
- {urlsToDisplay.length > 0 && - urlsToDisplay.map((link) => ( - - - {link.title || link.url} - - ))} -
); } 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):
)} - +
@@ -421,23 +400,3 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
); } - -function RelatedSourceIcon(): JSX.Element { - return ( - - - - - - - ); -} diff --git a/packages/docsearch-react/src/ConversationHistoryScreen.tsx b/packages/docsearch-react/src/ConversationHistoryScreen.tsx index 810d392b..82a7c3ff 100644 --- a/packages/docsearch-react/src/ConversationHistoryScreen.tsx +++ b/packages/docsearch-react/src/ConversationHistoryScreen.tsx @@ -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, '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 (
{ const { buttonText = 'Search', buttonAriaLabel = 'Search' } = translations; const resolvedShortcuts = getKeyboardShortcuts(keyboardShortcuts); + const { actionKeyReactsTo, actionKeyAltText, actionKeyLabel, key } = usePlatformKeys(); - const [key, setKey] = useState(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`; diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index e4eff0c8..fe7cf93f 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -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 extends AutocompleteApi { @@ -21,6 +23,7 @@ interface ResultsProps onItemClick: (item: TItem, event: KeyboardEvent | MouseEvent) => void; hitComponent: DocSearchProps['hitComponent']; state: AutocompleteState; + sourceIcon?: JSX.Element; } export function Results(props: ResultsProps): JSX.Element | null { @@ -45,8 +48,9 @@ export function Results(props: ResultsProps -
    +
    +
    Ask AI Assistant
    +
    @@ -56,8 +60,11 @@ export function Results(props: ResultsProps -
    {decodedTitle}
    -
      +
      + + {decodedTitle} +
      +
        {props.collection.items.map((item, index) => { return ; })} @@ -68,9 +75,12 @@ export function Results(props: ResultsProps -
        {decodedTitle}
        +
        + {props.sourceIcon ?? null} + {decodedTitle} +
        -
          +
            {props.collection.items.map((item, index) => { return ; })} @@ -93,8 +103,13 @@ function Result({ onItemClick, collection, hitComponent, + translations = {}, }: ResultProps): 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 (
          • ({
            {renderIcon({ item, index })} - {item.hierarchy[item.type] && item.type === 'lvl1' && ( -
            - - {item.content && } -
            - )} - {item.type === 'askAI' && (
            {sanitizeUserInput(item.hierarchy.lvl1 || '')} + {relativeDate || recentConversationTimestampFallback} +
            + )} + + {item.hierarchy[item.type] && item.type === 'lvl1' && ( +
            + + {item.hierarchy.lvl0 && }
            )} @@ -164,15 +180,9 @@ function AskAiButton({ item, getItemProps, onItemClick, - translations, collection, - ...props -}: AskAiButtonProps): 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): JSX.Element | null { + if (!item.query) return null; return (
          • ({
- {placeholder} - {String(item.query || '')} + {item.query}
diff --git a/packages/docsearch-react/src/ResultsScreen.tsx b/packages/docsearch-react/src/ResultsScreen.tsx index 5a6bbd5a..40c42a4f 100644 --- a/packages/docsearch-react/src/ResultsScreen.tsx +++ b/packages/docsearch-react/src/ResultsScreen.tsx @@ -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, 'translations'> & { @@ -16,6 +16,8 @@ type ResultsScreenProps = Omit, 'translat }; export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProps): JSX.Element { + const { resultsSectionTitle = 'Results' } = translations; + return (
{props.state.collections.map((collection) => { @@ -23,39 +25,17 @@ export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProp return null; } - const title = removeHighlightTags(collection.items[0]); - return ( ( - <> - {item.__docsearch_parent && ( - - - {item.__docsearch_parent !== collection.items[index + 1]?.__docsearch_parent ? ( - - ) : ( - - )} - - - )} - -
- -
- + renderIcon={({ item }) => ( +
+ +
)} renderAction={() => (
diff --git a/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx b/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx index b6fa1203..f73d7872 100644 --- a/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx +++ b/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx @@ -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
{status === 'error' && streamError && isLastExchange && ( -
- +
+
- - {urlsToDisplay.length > 0 ? ( - - ) : null}
); }, diff --git a/packages/docsearch-react/src/__tests__/api.test.tsx b/packages/docsearch-react/src/__tests__/api.test.tsx index 534ca094..723e460f 100644 --- a/packages/docsearch-react/src/__tests__/api.test.tsx +++ b/packages/docsearch-react/src/__tests__/api.test.tsx @@ -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(); diff --git a/packages/docsearch-react/src/__tests__/searchbox.test.tsx b/packages/docsearch-react/src/__tests__/searchbox.test.tsx index e266b30d..e620137d 100644 --- a/packages/docsearch-react/src/__tests__/searchbox.test.tsx +++ b/packages/docsearch-react/src/__tests__/searchbox.test.tsx @@ -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( ()} isFromSelection={false} diff --git a/packages/docsearch-react/src/__tests__/startscreen.test.tsx b/packages/docsearch-react/src/__tests__/startscreen.test.tsx index eaea7312..613bbeed 100644 --- a/packages/docsearch-react/src/__tests__/startscreen.test.tsx +++ b/packages/docsearch-react/src/__tests__/startscreen.test.tsx @@ -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(); @@ -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(); @@ -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( diff --git a/packages/docsearch-react/src/components/AskAiSearchBox.tsx b/packages/docsearch-react/src/components/AskAiSearchBox.tsx index 5f1691e0..66dcff04 100644 --- a/packages/docsearch-react/src/components/AskAiSearchBox.tsx +++ b/packages/docsearch-react/src/components/AskAiSearchBox.tsx @@ -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): 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): void => { diff --git a/packages/docsearch-react/src/components/AskAiStartScreen.tsx b/packages/docsearch-react/src/components/AskAiStartScreen.tsx index 8c4ffd7d..3c80379d 100644 --- a/packages/docsearch-react/src/components/AskAiStartScreen.tsx +++ b/packages/docsearch-react/src/components/AskAiStartScreen.tsx @@ -18,8 +18,8 @@ type AskAiStartScreenProps = Omit, ' export function AskAiStartScreen({ translations = {}, ...props }: AskAiStartScreenProps): JSX.Element | null { return (
- +
); } diff --git a/packages/docsearch-react/src/components/KeywordSearchBox.tsx b/packages/docsearch-react/src/components/KeywordSearchBox.tsx index 1a3d0f81..0b1ed9fe 100644 --- a/packages/docsearch-react/src/components/KeywordSearchBox.tsx +++ b/packages/docsearch-react/src/components/KeywordSearchBox.tsx @@ -35,6 +35,23 @@ export function KeywordSearchBox({ translations = {}, ...props }: KeywordSearchB searchInputLabel = 'Search', } = translations; + const actionsBeforeClose = ( + <> + + + {props.state.query &&
} + + ); + return ( ); } diff --git a/packages/docsearch-react/src/components/SourcesPanel.tsx b/packages/docsearch-react/src/components/SourcesPanel.tsx new file mode 100644 index 00000000..dc9ab1ea --- /dev/null +++ b/packages/docsearch-react/src/components/SourcesPanel.tsx @@ -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(null); + const [boundary, setBoundary] = React.useState(null); + + useEffect(() => { + if (!open) { + return undefined; + } + + const trigger = triggerRef.current; + const scrollContainer = + trigger?.closest('.DocSearch-Sidepanel-Content') ?? + trigger?.closest('.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 ( + + + + + + {links.length} sources + + + {titleText} + + + + ); +} diff --git a/packages/docsearch-react/src/components/ToolCall.tsx b/packages/docsearch-react/src/components/ToolCall.tsx index 18ffbc28..bf1c8ca2 100644 --- a/packages/docsearch-react/src/components/ToolCall.tsx +++ b/packages/docsearch-react/src/components/ToolCall.tsx @@ -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 ( } variant="Result"> @@ -96,8 +95,7 @@ function SearchTool({ part, translations, onSearchQueryClick }: SearchToolProps) ) : ( "{query || ''}" - )}{' '} - found {numberOfHits} results + )} ); diff --git a/packages/docsearch-react/src/__tests__/FeedbackActions.test.tsx b/packages/docsearch-react/src/components/__tests__/FeedbackActions.test.tsx similarity index 97% rename from packages/docsearch-react/src/__tests__/FeedbackActions.test.tsx rename to packages/docsearch-react/src/components/__tests__/FeedbackActions.test.tsx index 8b815398..3508a90a 100644 --- a/packages/docsearch-react/src/__tests__/FeedbackActions.test.tsx +++ b/packages/docsearch-react/src/components/__tests__/FeedbackActions.test.tsx @@ -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', diff --git a/packages/docsearch-react/src/__tests__/ToolCall.test.tsx b/packages/docsearch-react/src/components/__tests__/ToolCall.test.tsx similarity index 92% rename from packages/docsearch-react/src/__tests__/ToolCall.test.tsx rename to packages/docsearch-react/src/components/__tests__/ToolCall.test.tsx index 7cfea76d..214b66a9 100644 --- a/packages/docsearch-react/src/__tests__/ToolCall.test.tsx +++ b/packages/docsearch-react/src/components/__tests__/ToolCall.test.tsx @@ -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(); + }>)('renders the completed search tool result for $description', ({ part }) => { + const { container } = render(); - expect(screen.getByText(`found ${expectedHits} results`, { exact: false })).toBeInTheDocument(); + expect(within(container).getByText('Searched', { exact: false })).toBeInTheDocument(); + expect(within(container).getByText(/"test"/)).toBeInTheDocument(); }); }); diff --git a/packages/docsearch-react/src/components/ui/Popover.tsx b/packages/docsearch-react/src/components/ui/Popover.tsx new file mode 100644 index 00000000..6fff47ec --- /dev/null +++ b/packages/docsearch-react/src/components/ui/Popover.tsx @@ -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 ; +} + +function PopoverTrigger({ className, ...props }: PopoverPrimitive.Trigger.Props) { + const cn = `DocSearch-Popover-Trigger${className ? ` ${className}` : ''}`; + return ; +} + +Popover.Trigger = PopoverTrigger; + +type PopoverPopupProps = Pick< + PopoverPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'collisionBoundary' | 'side' | 'sideOffset' +> & + Pick & + PopoverPrimitive.Popup.Props; + +function PopoverPopup({ + align = 'center', + alignOffset = 0, + side = 'bottom', + sideOffset = 4, + collisionBoundary = 'clipping-ancestors', + container, + children, + ...props +}: PopoverPopupProps) { + return ( + + + + + + + + {children} + + + + ); +} + +Popover.Popup = PopoverPopup; + +function PopoverTitle({ ...props }: PopoverPrimitive.Title.Props) { + return ; +} + +Popover.Title = PopoverTitle; diff --git a/packages/docsearch-react/src/components/ui/RecentConversationsResults.tsx b/packages/docsearch-react/src/components/ui/RecentConversationsResults.tsx index b86d5670..4b5c25d1 100644 --- a/packages/docsearch-react/src/components/ui/RecentConversationsResults.tsx +++ b/packages/docsearch-react/src/components/ui/RecentConversationsResults.tsx @@ -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 ( -
- +
+
)} renderAction={({ item }) => ( diff --git a/packages/docsearch-react/src/components/ui/SearchBoxForm.tsx b/packages/docsearch-react/src/components/ui/SearchBoxForm.tsx index 50975551..2405bca3 100644 --- a/packages/docsearch-react/src/components/ui/SearchBoxForm.tsx +++ b/packages/docsearch-react/src/components/ui/SearchBoxForm.tsx @@ -100,19 +100,6 @@ export function SearchBoxForm({ />
- - - {state.query &&
} - {actionsBeforeClose} + {favoriteSearches && ( + } + renderIcon={({ item }) => ( +
+
+ )} + renderAction={({ item }) => (
- - )} - /> + )} + /> + )} - ( -
- -
- )} - renderAction={({ item }) => ( -
- -
- )} - /> + {recentSearchesCollection && ( + } + renderIcon={({ item }) => ( +
+ +
+ )} + renderAction={({ item }) => ( + <> +
+ +
+
+ +
+ + )} + /> + )} ); } diff --git a/packages/docsearch-react/src/__tests__/modal.test.tsx b/packages/docsearch-react/src/hooks/__tests__/modal.test.tsx similarity index 93% rename from packages/docsearch-react/src/__tests__/modal.test.tsx rename to packages/docsearch-react/src/hooks/__tests__/modal.test.tsx index 14d91265..bf238fa6 100644 --- a/packages/docsearch-react/src/__tests__/modal.test.tsx +++ b/packages/docsearch-react/src/hooks/__tests__/modal.test.tsx @@ -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(() => { diff --git a/packages/docsearch-react/src/hooks/__tests__/useRelativeFormattedDate.test.tsx b/packages/docsearch-react/src/hooks/__tests__/useRelativeFormattedDate.test.tsx new file mode 100644 index 00000000..873e6530 --- /dev/null +++ b/packages/docsearch-react/src/hooks/__tests__/useRelativeFormattedDate.test.tsx @@ -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'); + }); +}); diff --git a/packages/docsearch-react/src/hooks/usePlatformKeys.ts b/packages/docsearch-react/src/hooks/usePlatformKeys.ts new file mode 100644 index 00000000..96517396 --- /dev/null +++ b/packages/docsearch-react/src/hooks/usePlatformKeys.ts @@ -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(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, + }; +} diff --git a/packages/docsearch-react/src/hooks/useRelativeFormattedDate.ts b/packages/docsearch-react/src/hooks/useRelativeFormattedDate.ts new file mode 100644 index 00000000..1f7bbeda --- /dev/null +++ b/packages/docsearch-react/src/hooks/useRelativeFormattedDate.ts @@ -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; +} diff --git a/packages/docsearch-react/src/icons/AlertIcon.tsx b/packages/docsearch-react/src/icons/AlertIcon.tsx index 1b65cbdc..5f446670 100644 --- a/packages/docsearch-react/src/icons/AlertIcon.tsx +++ b/packages/docsearch-react/src/icons/AlertIcon.tsx @@ -1,6 +1,6 @@ import React, { type JSX } from 'react'; -export function AlertIcon(): JSX.Element { +export function AlertIcon(props: React.SVGProps): JSX.Element { return ( diff --git a/packages/docsearch-react/src/icons/MessageIcon.tsx b/packages/docsearch-react/src/icons/MessageIcon.tsx new file mode 100644 index 00000000..81869044 --- /dev/null +++ b/packages/docsearch-react/src/icons/MessageIcon.tsx @@ -0,0 +1,19 @@ +import React, { type JSX } from 'react'; + +export function MessageIcon(): JSX.Element { + return ( + + + + ); +} diff --git a/packages/docsearch-react/src/icons/PinIcon.tsx b/packages/docsearch-react/src/icons/PinIcon.tsx new file mode 100644 index 00000000..b533f300 --- /dev/null +++ b/packages/docsearch-react/src/icons/PinIcon.tsx @@ -0,0 +1,20 @@ +import React, { type JSX } from 'react'; + +export function PinIcon(): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/docsearch-react/src/icons/SourceIcon.tsx b/packages/docsearch-react/src/icons/SourceIcon.tsx index cc00d5da..a05a2f7d 100644 --- a/packages/docsearch-react/src/icons/SourceIcon.tsx +++ b/packages/docsearch-react/src/icons/SourceIcon.tsx @@ -53,16 +53,23 @@ function AnchorIcon(): JSX.Element { ); } -function ContentIcon(): JSX.Element { +export function ContentIcon(): JSX.Element { return ( - - + + + + + + ); } diff --git a/packages/docsearch-react/src/icons/index.ts b/packages/docsearch-react/src/icons/index.ts index 675749f1..e5a40e67 100644 --- a/packages/docsearch-react/src/icons/index.ts +++ b/packages/docsearch-react/src/icons/index.ts @@ -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'; diff --git a/packages/docsearch-react/src/utils/ai.ts b/packages/docsearch-react/src/utils/ai.ts index a17a0d8f..1ce4d2b5 100644 --- a/packages/docsearch-react/src/utils/ai.ts +++ b/packages/docsearch-react/src/utils/ai.ts @@ -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, diff --git a/packages/docsearch-react/src/utils/collections.ts b/packages/docsearch-react/src/utils/collections.ts new file mode 100644 index 00000000..1436baec --- /dev/null +++ b/packages/docsearch-react/src/utils/collections.ts @@ -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 = AutocompleteState['collections'][number]; + +export function getCollection( + state: { collections: Array | null | undefined> }, + sourceId: KnownSourceId, +): Collection | undefined { + return state.collections.find( + (collection): collection is Collection => collection?.source.sourceId === sourceId, + ); +} diff --git a/packages/docsearch-react/src/utils/createAskAiSources.ts b/packages/docsearch-react/src/utils/createAskAiSources.ts index 545a87ef..3e3e944a 100644 --- a/packages/docsearch-react/src/utils/createAskAiSources.ts +++ b/packages/docsearch-react/src/utils/createAskAiSources.ts @@ -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> { 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 [ { diff --git a/packages/docsearch-react/src/utils/createDocSearchSources.ts b/packages/docsearch-react/src/utils/createDocSearchSources.ts index 7a5e0ed6..01c797d0 100644 --- a/packages/docsearch-react/src/utils/createDocSearchSources.ts +++ b/packages/docsearch-react/src/utils/createDocSearchSources.ts @@ -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(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') { diff --git a/packages/docsearch-react/src/utils/index.ts b/packages/docsearch-react/src/utils/index.ts index 4a64e02b..acc6081e 100644 --- a/packages/docsearch-react/src/utils/index.ts +++ b/packages/docsearch-react/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './collections'; export * from './groupBy'; export * from './identity'; export * from './isModifierEvent';