Compare commits

..

14 Commits

Author SHA1 Message Date
Nolann B. 5997a66638 Remove duplicated declarations from the global stylesheet (#4764)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 18:23:37 +01:00
Nolann B. 65210b7d1d Respect reduced motion and forced colors, use dynamic viewport units (#4763)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 18:23:37 +01:00
Nolann B. 265ca4bd9d Improve keyboard and screen reader support (#4761)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 18:23:36 +01:00
Nolann B. 3e86382cdc Tighten the referrer policy and the embed channel handshake (#4759)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 18:23:36 +01:00
Nolann B. 19660edf91 Fix quick accessibility and input bugs, tune network priorities (#4758)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 18:23:35 +01:00
Nolann B. e3d22380fc Improve preview deployment workflow reliability (#4772)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 16:58:21 +00:00
Nolann B. 9b5b8c85ec Load the OpenAPI stylesheet only on pages with OpenAPI blocks (#4770) 2026-11-12 16:21:51 +01:00
Nolann B. 0c3a51eca4 Stop card carousels from widening the page (#4768)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-12 15:31:58 +01:00
Tomek 7d1cd9f9df Remove ChatGPT-specific plain text Markdown responses (#4766) 2026-11-12 10:38:07 +00:00
Samy Pessé 6029a31c07 Version Packages (#4755)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-12 10:34:04 +00:00
Nolann B. 4fb7e14436 Push the release PR with the PAT so its workflows run (#4738) 2026-11-12 10:18:08 +00:00
Steven H 31e88d16eb Relicense @gitbook/embed under Apache 2.0 (#4765)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:33:54 +00:00
Peter White 7ddd023d72 Fix search prewarming on public sites (#4762) 2026-11-12 10:24:29 +01:00
Peter White b53e73c0e7 Link suggested follow-up questions to the markdown ask route (#4757) 2026-11-12 08:57:20 +01:00
130 changed files with 1531 additions and 737 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix horizontal page scroll caused by card carousels bleeding past the viewport.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove duplicated declarations from the global stylesheet.
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/icons": patch
---
Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Flush insights events when the page is hidden, preload the search index at low priority and prioritize the dark page cover on dark-default sites.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the page outline not scrolling to keep the active section visible on long pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the search button accessible name, hoverable link previews, IME-safe Enter on Safari, cookie banner semantics, active outline item announcement and textarea shrinking.
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Respect reduced motion for enter and exit animations, keep the header separator and input focus visible in forced colors mode, and size mobile surfaces with dynamic viewport units.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Send only the site origin as referrer to third parties, and only let the embedding page open the embed messaging channel.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Wait for server results before Enter opens a result in site search, and route search prewarm requests on statically served sites.
+2
View File
@@ -87,3 +87,5 @@ The site must be [published publicly](https://gitbook.com/docs/published-documen
## License
The renderer is distributed under the [GNU GPLv3 License](https://github.com/GitbookIO/gitbook/blob/main/LICENSE). If you distribute the code, you must keep the source code public. To use it in a private repository, you need a [commercial license](https://www.gitbook.com/pricing).
The [`@gitbook/embed`](https://github.com/GitbookIO/gitbook/blob/main/packages/embed) package is distributed under the [Apache License 2.0](https://github.com/GitbookIO/gitbook/blob/main/packages/embed/LICENSE). Contributions to it are made under the same license.
+42 -16
View File
@@ -4,6 +4,11 @@ on:
push:
branches:
- main
# A newer run for the same PR supersedes the previous one. Each push to main gets a group of its
# own, so main runs are never queued behind or cancelled by another run.
concurrency:
group: ${{ github.event_name == 'pull_request_target' && format('preview-pr-{0}', github.event.pull_request.number) || format('preview-run-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
@@ -18,8 +23,9 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -44,8 +50,9 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
if: ${{ !cancelled() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -82,7 +89,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: always() && github.event_name != 'push'
if: ${{ !cancelled() && github.event_name != 'push' }}
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -95,6 +102,25 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
# A deploy that didn't succeed has no URL (e.g. a re-run hitting Vercel's "deploymentId
# already exists"), so keep the URL the comment already shows instead of blanking it.
- name: Resolve deployment URLs
id: urls
env:
PREVIOUS_COMMENT: ${{ steps.fc.outputs.comment-body }}
VERCEL_RESULT: ${{ needs.deploy-v2-vercel.result }}
VERCEL_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
CLOUDFLARE_RESULT: ${{ needs.deploy-v2-cloudflare.result }}
CLOUDFLARE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
run: |
previous_url() {
sed -n "s/^| $1 | \[\([^]]*\)\].*/\1/p" <<<"$PREVIOUS_COMMENT" | head -n 1
}
if [ "$VERCEL_RESULT" != success ]; then VERCEL_URL=$(previous_url Vercel); fi
if [ "$CLOUDFLARE_RESULT" != success ]; then CLOUDFLARE_URL=$(previous_url Cloudflare); fi
echo "vercel=$VERCEL_URL" >> "$GITHUB_OUTPUT"
echo "cloudflare=$CLOUDFLARE_URL" >> "$GITHUB_OUTPUT"
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
with:
@@ -105,21 +131,21 @@ jobs:
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
| Vercel | [${{ steps.urls.outputs.vercel }}](${{ steps.urls.outputs.vercel }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ steps.urls.outputs.cloudflare }}](${{ steps.urls.outputs.cloudflare }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | `2v` | `2c` |
| --- | --- | --- |
| GitBook | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
| E2E | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
| GitBook | [${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs](${{ steps.urls.outputs.vercel }}/url/gitbook.com/docs) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs](${{ steps.urls.outputs.cloudflare }}/url/gitbook.com/docs) |
| E2E | [${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.vercel }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open](${{ steps.urls.outputs.cloudflare }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v2-vercel:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -143,7 +169,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -167,7 +193,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -191,7 +217,7 @@ jobs:
# Style recalc depends on the client bundle, not the host, so this runs here only and
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
- name: Run style invalidation tests
if: always()
if: ${{ !cancelled() }}
working-directory: packages/gitbook
run: bun run e2e-style-perf
env:
@@ -200,7 +226,7 @@ jobs:
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
- name: Build style invalidation report
if: always() && github.event_name != 'push'
if: ${{ !cancelled() && github.event_name != 'push' }}
id: style-perf
working-directory: packages/gitbook
run: |
@@ -219,7 +245,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Find style invalidation comment
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
uses: peter-evans/find-comment@v3
id: fc-style-perf
with:
@@ -228,7 +254,7 @@ jobs:
body-includes: 'Style invalidation on a large API reference'
- name: Create or update style invalidation comment
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
if: ${{ !cancelled() && github.event_name != 'push' && steps.style-perf.outputs.body != '' }}
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
@@ -239,7 +265,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
if: ${{ !cancelled() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 30
steps:
- name: Checkout
@@ -263,7 +289,7 @@ jobs:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
if: ${{ !cancelled() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
+2
View File
@@ -25,6 +25,8 @@ jobs:
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
# Otherwise git pushes with GITHUB_TOKEN instead of the PAT, and the release PR never triggers Preview
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
+2
View File
@@ -124,6 +124,8 @@ If you plan to distribute the code, you must make the source code public to comp
See `LICENSE` for more information.
The [`@gitbook/embed`](packages/embed) package is an exception: it is distributed under the [Apache License 2.0](packages/embed/LICENSE), so it can be used in proprietary applications.
## Badges
<p align="left">
+10 -11
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.90.0",
"oxlint": "^1.93.0",
"turbo": "^2.11.10",
"vercel": "50.37.3",
},
@@ -49,10 +49,9 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.6.0",
"version": "0.7.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:",
},
"devDependencies": {
@@ -110,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.28.1",
"version": "0.28.2",
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
@@ -1363,17 +1362,17 @@
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.11.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-By4idRap5+nLnMuRzLB4qkOPRlvNdyY1ne4VJYDyUoUB11H94k7uif9mbe3JT8JSNXs1D68uMcBj6hJ/jMQRvA=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.11.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-HMMeUIEAsmyIbSYn1ySvF/4gGMa0joiTMq4dQDuloIAnOpbtFT3JEiVsYnASdTWDXvplbRbZlqjU1EBeSR9G1A=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pyEYlVcuexOPa92p0Wlwl9YT1Is5D60lxN3ItWm3g30YLvFaUq86TF4BBWg5ZAc0uMTnrOzCvuCsOBTdzUWnRQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+8iySNgqYWY6PZYUQzYu6zn0C04z2OEq1vQ6oMGsJ9sxN6wOpg1gzi1AJ5VKDHviWvMUjhqng51hTEQZOuiqYQ=="],
"@turbo/linux-64": ["@turbo/linux-64@2.11.10", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-1tYeR/IDZ5HEdsb0jNq2L2xam5nMQ+g80tlV2BZmv4OFw+pMcUM0mMmUcfc5wXPPNHVNPuDMjh9Y8G8HgXPLZA=="],
"@turbo/linux-64": ["@turbo/linux-64@2.11.11", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-uImTKFYCFKsoFxjaXeJXlMPv4uU3OIIDjyzoIwYVqOtu9LnCZ/5/fzu/WP6+G9YfwGAPRplTHkKFfcI5t97RGw=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.10", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-HuekQxvVGhUd46g1Im/DKdOtglHr4/gguZAvzEpPUgtxNkztSa8lWhQEGQ+EmPQ0n7w19qjThFU+OfEEd0uvlw=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.11.11", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-Y7xffSVfyyYRGzNy6PLdrFL4xXN6iTuR3W0SyJvH/yd3d4defA6dansZwJpPDVnIpD8a2Qn256DwY/Qin4Ip4g=="],
"@turbo/windows-64": ["@turbo/windows-64@2.11.10", "", { "os": "win32", "cpu": "x64" }, "sha512-UJOP3BkbreY3/JlMgJR6ZiimxYIBn+c+B4tnBIxD0E9c3Estmk+XoVNVBNBottZLNRXe3/ScgOU4+/k8tIWysg=="],
"@turbo/windows-64": ["@turbo/windows-64@2.11.11", "", { "os": "win32", "cpu": "x64" }, "sha512-e2WqNtkPGo3QDwNNwwBo89Dx3PtSJ9Wp9QuRNzn80rrRnSHB+1mfBMZTpdHjA8/G/sG9rl76TdWdpgVsimjRdg=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-xOkEjF3ws/b/YNlc+bvJHMYBn+vL+QbEISZFVi0qS+ImmhA1X3/gmNmBrjjIG3dg6aeN+n6gQitzSkXQ286qJQ=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.11.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-LURivfmgv34Osm8NR1gZGx4vdlux9KAgRM+FoooXNG0838ff0+akWZ76iPSke0NS2rsf7Z4Gw+YSaygzNTZuRA=="],
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
@@ -3131,7 +3130,7 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.11.10", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.10", "@turbo/darwin-arm64": "2.11.10", "@turbo/linux-64": "2.11.10", "@turbo/linux-arm64": "2.11.10", "@turbo/windows-64": "2.11.10", "@turbo/windows-arm64": "2.11.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-3GJYwig8ygEI89oaOZdduJX8/aw6O9eRPYHoIt6T3FZl9hkKjiyIspufJoVgqXihPoJu8M5CSV0DlZvzfohBCw=="],
"turbo": ["turbo@2.11.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.11", "@turbo/darwin-arm64": "2.11.11", "@turbo/linux-64": "2.11.11", "@turbo/linux-arm64": "2.11.11", "@turbo/windows-64": "2.11.11", "@turbo/windows-arm64": "2.11.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AI4UJbUE/2cVZdoXJ4PODc8qvrjhQQX9Fa93eKGOI6Ims/w7+kUIb9T1jhlgSnZcf+gtUruci/ohSwqtNNC5Yw=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
+1 -1
View File
@@ -8,7 +8,7 @@
"@changesets/cli": "^2.31.1",
"oxfmt": "^0.62.0",
"oxlint": "^1.93.0",
"turbo": "^2.11.10",
"turbo": "^2.11.11",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/embed
## 0.7.0
### Minor Changes
- 31e88d1: Relicense under Apache 2.0 and drop the dependency on `@gitbook/icons`.
## 0.6.0
### Minor Changes
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+4
View File
@@ -542,3 +542,7 @@ button: {
icon: 'assistant'
}
```
## License
Distributed under the [Apache License 2.0](./LICENSE).
+4 -3
View File
@@ -13,10 +13,10 @@
}
},
"sideEffects": false,
"version": "0.6.0",
"version": "0.7.0",
"license": "Apache-2.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:"
},
"peerDependencies": {
@@ -42,7 +42,8 @@
"files": [
"dist",
"README.md",
"CHANGELOG.md"
"CHANGELOG.md",
"LICENSE"
],
"publishConfig": {
"access": "public",
+6 -1
View File
@@ -1,5 +1,10 @@
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
/**
* Name of a Font Awesome icon (e.g. `bolt`), or a GitBook custom icon.
*/
// Kept as a plain string so the package doesn't depend on @gitbook/icons and its licence.
type IconName = string;
/**
* Confirmation action to be displayed to the user before executing a tool.
+2
View File
@@ -158,6 +158,8 @@
z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- the `vh` declaration above is the fallback on host pages whose browser lacks `dvh` */
height: calc(min(var(--gitbook-widget-window-height), calc(100dvh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color);
+10
View File
@@ -1,5 +1,15 @@
# gitbook
## 0.28.2
### Patch Changes
- b53e73c: Link suggested follow-up questions in markdown AI answers to the `.md` ask route so agents get markdown back.
- 4a40547: Fix the page outline not scrolling to keep the active section visible on long pages.
- 7ddd023: Fix search prewarming on public sites.
- Updated dependencies [31e88d1]
- @gitbook/embed@0.7.0
## 0.28.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.28.1",
"version": "0.28.2",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
@@ -1,14 +0,0 @@
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
export async function POST() {
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const apiClient = await context.dataFetcher.api();
await apiClient.orgs.prewarmSiteSearch(siteURLData.organization, siteURLData.site);
return new Response(null, { status: 204 });
}
@@ -57,7 +57,6 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<div
role="dialog"
data-testid="cookies-dialog"
aria-modal="true"
aria-label={tString(language, 'cookies_title')}
aria-describedby={describedById}
className={tcls(
@@ -1,8 +1,7 @@
'use client';
import React from 'react';
import { getCodeTextFromId } from './utils';
import { useCopyToClipboard } from '@/components/hooks';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -15,41 +14,30 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
const { codeId, style } = props;
const language = useLanguage();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, 1000);
return () => {
clearTimeout(timeout);
};
}, [copied]);
const { copied, copy } = useCopyToClipboard();
const onClick = () => {
const codeText = getCodeTextFromId(codeId);
if (codeText === null) {
return;
}
navigator.clipboard.writeText(codeText);
setCopied(true);
copy(codeText);
};
return (
<Button
size="xsmall"
variant="secondary"
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden')}
>
{t(language, copied ? 'code_copied' : 'code_copy')}
</Button>
<>
<Button
size="xsmall"
variant="secondary"
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden')}
>
{t(language, copied ? 'code_copied' : 'code_copy')}
</Button>
{/* A label swap on the focused button isn't announced, the live region is. */}
<span role="status" className="sr-only">
{copied ? t(language, 'code_copied') : null}
</span>
</>
);
}
@@ -129,7 +129,7 @@
.contentkit-modal {
@apply flex flex-col gap-6 relative z-50 bg-tint
rounded shadow-lg opacity-0 transition-opacity duration-300 border border-tint
w-full max-w-[768px] max-h-[90vh] overflow-y-auto;
w-full max-w-[768px] max-h-[90dvh] overflow-y-auto;
}
.contentkit-modal.contentkit-modal-opened {
@apply opacity-[1];
@@ -16,6 +16,8 @@ import { matchesText } from '../Table/searchMatch';
import { MIN_RECORDS_FOR_SEARCH } from '../Table/shouldShowSearch';
import { Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
// Imported here, behind the `next/dynamic` boundary, so only pages with OpenAPI blocks load it.
import './style.css';
// Only what survives the RSC boundary. Rebuilding the renderers on the client is what lets this
// whole subtree sit behind a `next/dynamic` chunk instead of the route's eager entry.
@@ -17,7 +17,6 @@ import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
import { Heading } from '../Heading';
import type { OpenAPIBlockClientContextProps } from './OpenAPIBlockClient';
import './style.css';
import { SCALAR_RUNTIME_PATH } from './scalar-runtime-path';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
@@ -0,0 +1,46 @@
/* Global, not in style.css: badges also show in the sidebar and outline of pages without OpenAPI blocks. */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -143,53 +143,6 @@ button.openapi-mcp {
@apply !mb-0;
}
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
/* URL */
.openapi-url {
@apply font-mono text-sm text-tint;
@@ -5,6 +5,7 @@ import React from 'react';
import { Icon } from '@gitbook/icons';
import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction';
import { useCopyToClipboard } from '@/components/hooks';
import {
Button,
ButtonGroup,
@@ -27,25 +28,10 @@ export function PromptActions(props: { prompt: string; openInAIProviders: boolea
const { prompt, openInAIProviders } = props;
const language = useLanguage();
const selectedAction = usePromptAction();
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => {
setCopied(false);
}, COPIED_MESSAGE_DURATION);
return () => {
clearTimeout(timeout);
};
}, [copied]);
const { copied, copy } = useCopyToClipboard(COPIED_MESSAGE_DURATION);
const copyPrompt = () => {
navigator.clipboard.writeText(prompt);
setCopied(true);
copy(prompt);
};
// The pick follows the visitor from site to site, so fall back to copying wherever the agent
@@ -43,13 +43,14 @@ describe('ViewCards', () => {
it('renders the carousel with a symmetric peek and conditional edge masks', () => {
const markup = renderWithContext(<ViewCards {...makeProps(false)} />);
expect(markup).toContain('-mx-12');
expect(markup).toContain('px-12');
expect(markup).toContain('scroll-px-12');
// Gutter-sized peek, the full 3rem from `2xl`.
expect(markup).toContain('-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12');
expect(markup).toContain('scroll-px-4');
expect(markup).toContain('2xl:scroll-px-12');
expect(markup).toContain('left-0');
expect(markup).toContain('ml-8');
expect(markup).toContain('2xl:ml-8');
expect(markup).toContain('right-0');
expect(markup).toContain('mr-8');
expect(markup).toContain('2xl:mr-8');
expect(markup).not.toContain('before:bg-linear-to-r');
expect(markup).not.toContain('after:bg-linear-to-l');
expect(markup).toContain('snap-mandatory');
@@ -49,6 +49,16 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
);
}
// The peek fits the page gutter (`CONTAINER_STYLE`) so the fade stays on screen; from `2xl` the
// cards fill the row exactly and need the full 3rem to show the next one.
const PEEK = {
margin: '-mx-4 sm:-mx-6 md:-mx-8 2xl:-mx-12',
padding:
'px-4 scroll-px-4 sm:px-6 sm:scroll-px-6 md:px-8 md:scroll-px-8 2xl:px-12 2xl:scroll-px-12',
leadingButton: 'ml-0 sm:ml-2 md:ml-4 2xl:ml-8',
trailingButton: 'mr-0 sm:mr-2 md:mr-4 2xl:mr-8',
};
/**
* The carousel layout: cards lay out in a single horizontally-scrolling row that
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
@@ -70,7 +80,7 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
return (
<ScrollContainer
orientation="horizontal"
className={tcls('-mx-12', 'hover:z-11')}
className={tcls(PEEK.margin, 'hover:z-11')}
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
contentClassName={tcls(
@@ -79,19 +89,18 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
'-mt-px',
'pb-6',
'-mb-6',
'px-12',
'scroll-px-12',
PEEK.padding,
'snap-x',
'snap-mandatory'
)}
scrollByVisibleItems
leading={{
fade: true,
button: { size: 'small', className: 'ml-8' },
button: { size: 'small', className: PEEK.leadingButton },
}}
trailing={{
fade: true,
button: { size: 'small', className: 'mr-8' },
button: { size: 'small', className: PEEK.trailingButton },
}}
>
{records.map((record) => {
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'bun:test';
import { getNextTabIndex } from './DynamicTabs';
describe('getNextTabIndex', () => {
it('moves with the arrow keys and wraps around', () => {
expect(getNextTabIndex('ArrowRight', 0, 3)).toBe(1);
expect(getNextTabIndex('ArrowRight', 2, 3)).toBe(0);
expect(getNextTabIndex('ArrowLeft', 0, 3)).toBe(2);
});
it('jumps to the first and last tab', () => {
expect(getNextTabIndex('Home', 2, 3)).toBe(0);
expect(getNextTabIndex('End', 0, 3)).toBe(2);
});
it('mirrors the arrows in right-to-left', () => {
expect(getNextTabIndex('ArrowLeft', 0, 3, true)).toBe(1);
expect(getNextTabIndex('ArrowRight', 0, 3, true)).toBe(2);
});
it('ignores other keys and an unknown tab', () => {
expect(getNextTabIndex('Enter', 0, 3)).toBeNull();
expect(getNextTabIndex('ArrowRight', -1, 3)).toBeNull();
});
});
@@ -151,10 +151,15 @@ const TabItemList = memo(function TabItemList(props: {
}).filter((x) => x !== undefined),
[overflowing, tabs]
);
// Roving tabindex: one tab stop for the strip, the active tab when it isn't in the overflow menu.
const visibleTabs = tabs.filter((tab) => !overflowing.has(getTabButtonId(tab.id)));
const focusableTabId =
visibleTabs.find((tab) => tab.id === activeTabId)?.id ?? visibleTabs[0]?.id ?? null;
return (
<div
ref={containerRef}
role="tablist"
onKeyDown={moveFocusBetweenTabs}
className={tcls(
'group/tabs',
'overflow-hidden',
@@ -182,6 +187,7 @@ const TabItemList = memo(function TabItemList(props: {
key={tab.id}
ref={itemRef}
isActive={tab.id === activeTabId}
isFocusable={tab.id === focusableTabId}
isMeasuring={isMeasuring}
tab={tab}
onSelect={onSelect}
@@ -241,16 +247,18 @@ function TabsDropdownMenu(props: {
const TabItem = memo(function TabItem(props: {
ref: React.Ref<HTMLButtonElement>;
isActive: boolean;
isFocusable: boolean;
isMeasuring: boolean;
tab: TabsItem;
onSelect: (tabId: string) => void;
}) {
const { ref, tab, isActive, isMeasuring, onSelect } = props;
const { ref, tab, isActive, isFocusable, isMeasuring, onSelect } = props;
return (
<TabButton
ref={ref}
role="tab"
aria-selected={isActive}
tabIndex={isFocusable ? 0 : -1}
aria-controls={tab.id}
id={getTabButtonId(tab.id)}
title={tab.title}
@@ -343,6 +351,55 @@ function TabButton(
);
}
/**
* Arrow keys, Home and End move focus between the tabs of a tablist; Enter or Space then selects.
*/
function moveFocusBetweenTabs(event: React.KeyboardEvent<HTMLElement>) {
const current = event.target instanceof HTMLElement ? event.target : null;
if (current?.getAttribute('role') !== 'tab') {
return;
}
const tabs = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])')
);
const index = tabs.indexOf(current);
const isRTL = getComputedStyle(event.currentTarget).direction === 'rtl';
const next = getNextTabIndex(event.key, index, tabs.length, isRTL);
if (next === null) {
return;
}
event.preventDefault();
tabs[next]?.focus();
}
/**
* Index of the tab to focus for a key press, or null when the key doesn't move focus.
*/
export function getNextTabIndex(
key: string,
index: number,
count: number,
isRTL = false
): number | null {
if (index < 0 || count === 0) {
return null;
}
const forward = isRTL ? 'ArrowLeft' : 'ArrowRight';
const backward = isRTL ? 'ArrowRight' : 'ArrowLeft';
switch (key) {
case forward:
return (index + 1) % count;
case backward:
return (index - 1 + count) % count;
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/**
* Get the ID for a tab button.
*/
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
import React, { useEffect, useRef } from 'react';
import { createStore, useStore } from 'zustand';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
import { integrationsAssistantTools } from '../Integrations';
@@ -107,7 +108,8 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) {
const { settings } = message;
embeddableConfiguration.setState(settings);
integrationsAssistantTools.setState({
tools: settings.tools,
// The embed types icons as plain strings to avoid depending on @gitbook/icons.
tools: settings.tools as GitBookIntegrationTool[],
});
const defaultPage =
@@ -47,8 +47,8 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
askQuery,
close,
cursor,
phase,
enterWaiting,
error,
fetching,
onInputKeyDown,
onResultSelect,
query,
@@ -65,16 +65,14 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
{ restoreLastQueryOnMount: true }
);
const fetching = phase === 'loading';
return (
<LinkContext value={linkContext}>
<SearchFrame
asEmbeddable={hasDocsTab}
askQuery={askQuery}
cursor={cursor}
phase={phase}
enterWaiting={enterWaiting}
error={error}
fetching={fetching}
fillHeight={true}
query={query}
results={results}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'bun:test';
import { isForeignHandshake } from './channel';
describe('isForeignHandshake', () => {
const parent = {} as MessageEventSource;
const other = {} as MessageEventSource;
it('accepts a handshake from the expected window', () => {
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: parent }, parent)).toBe(
false
);
});
it('rejects a handshake from another window', () => {
expect(isForeignHandshake({ data: { type: 'bidc-connect' }, source: other }, parent)).toBe(
true
);
});
it('ignores messages that are not handshakes', () => {
expect(isForeignHandshake({ data: { type: 'other' }, source: other }, parent)).toBe(false);
expect(isForeignHandshake({ data: null, source: other }, parent)).toBe(false);
});
});
@@ -9,11 +9,33 @@ export function log(...data: any[]) {
console.log(...data);
}
/**
* Whether a message is a bidc handshake sent by a window other than the expected one.
*/
export function isForeignHandshake(
event: Pick<MessageEvent, 'data' | 'source'>,
expectedSource: MessageEventSource
): boolean {
return event.data?.type === 'bidc-connect' && event.source !== expectedSource;
}
export const getChannel = memoize(() => {
if (typeof window === 'undefined' || window.parent === window) {
return null;
}
log('[gitbook] create channel with parent window');
// bidc accepts a handshake from any window; registered first and in capture so it runs before
// bidc's own listener and only the embedding page can open the channel.
window.addEventListener(
'message',
(event) => {
if (isForeignHandshake(event, window.parent)) {
event.stopImmediatePropagation();
}
},
true
);
return createChannel();
});
@@ -55,6 +55,8 @@ export async function Header(props: {
'flex-none',
'shadow-[0px_1px_0px]',
'shadow-tint-12/2',
// Box shadows are dropped in forced colors mode, the separator needs a real border.
'forced-colors:border-b',
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
@@ -9,7 +9,7 @@ interface HeaderLinksProps {
export async function HeaderLinks({ children, style }: HeaderLinksProps) {
return (
<div
<nav
className={tcls(
'header-links',
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
@@ -17,6 +17,6 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
)}
>
{children}
</div>
</nav>
);
}
@@ -193,13 +193,18 @@ export function InsightsProvider(props: InsightsProviderProps) {
}
);
/**
* When the page is unloaded, flush all events.
*/
// `beforeunload` doesn't fire reliably on mobile, hiding the page is the last dependable signal.
React.useEffect(() => {
window.addEventListener('beforeunload', flushEventsSync);
const flushWhenHidden = () => {
if (document.visibilityState === 'hidden') {
flushEventsSync();
}
};
document.addEventListener('visibilitychange', flushWhenHidden);
window.addEventListener('pagehide', flushEventsSync);
return () => {
window.removeEventListener('beforeunload', flushEventsSync);
document.removeEventListener('visibilitychange', flushWhenHidden);
window.removeEventListener('pagehide', flushEventsSync);
};
}, [flushEventsSync]);
@@ -82,6 +82,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
>
<a
href={`#${section.id}`}
aria-current={activeId === section.id ? 'true' : undefined}
data-cover-aware-text
className={tcls(
'relative',
@@ -16,6 +16,7 @@ import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PreservePageLayout } from './PreservePageLayout';
import { MAIN_CONTENT_ID } from '@/components/layout';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
@@ -103,6 +104,7 @@ export async function PageBody(props: {
/>
) : null}
<main
id={MAIN_CONTENT_ID}
// Comments on the whole page target anywhere in it that isn't a block.
{...(withCommentAnchors ? { 'data-comment-page': '' } : {})}
className={tcls(
@@ -1,7 +1,11 @@
import type { StaticImageData } from 'next/image';
import { assert } from 'ts-essentials';
import type { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
import {
CustomizationDefaultThemeMode,
type RevisionPageDocument,
type RevisionPageDocumentCover,
} from '@gitbook/api';
import { CONTENT_STYLE } from '../layout';
import { getCoverHeight } from './coverHeight';
@@ -144,6 +148,11 @@ export async function PageCover(props: {
y={cover.yPos}
height={height}
mask={mask}
priorityTheme={
context.customization.themes.default === CustomizationDefaultThemeMode.Dark
? 'dark'
: 'light'
}
/>
</div>
</>
@@ -25,10 +25,14 @@ interface PageCoverImageProps {
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
height: number | undefined;
mask?: 'none' | 'radial';
/** Theme most visitors see first, its cover is the LCP candidate. */
priorityTheme?: 'light' | 'dark';
}
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { imgs, y, height, mask, priorityTheme = 'light' } = props;
// The light cover is never demoted to low: a visitor can still have switched to the light theme.
const darkFirst = priorityTheme === 'dark' && Boolean(imgs.dark);
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
@@ -48,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
src={imgs.light.src}
srcSet={imgs.light.srcSet}
sizes={imgs.light.sizes}
fetchPriority="high"
fetchPriority={darkFirst ? 'auto' : 'high'}
alt="Page cover"
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
style={{
@@ -69,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
src={imgs.dark.src}
srcSet={imgs.dark.srcSet}
sizes={imgs.dark.sizes}
fetchPriority="low"
fetchPriority={darkFirst ? 'high' : 'low'}
alt="Page cover"
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
style={{
@@ -56,7 +56,6 @@ function preloadFont(fontData: FontData) {
ReactDOM.preload(url, {
as: 'font',
crossOrigin: 'anonymous',
fetchPriority: 'high',
type: format ? `font/${format}` : undefined,
});
});
@@ -3,6 +3,7 @@
@config '../../../tailwind.config.ts';
@import "./prose.css";
@import "../DocumentView/OpenAPI/method-badge.css";
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
@@ -33,7 +34,6 @@
@utility hide-scrollbar {
/* Hide scrollbar by default */
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s ease-in-out;
&:hover {
/* Show scrollbar on hover */
@@ -52,21 +52,14 @@
rgba(0, 0, 0, 1) 96px,
rgba(0, 0, 0, 0)
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 96px,
rgba(0, 0, 0, 0)
);
}
@utility linear-mask-util {
mask-image: linear-gradient(to bottom, white, white);
mask-image: linear-gradient(to bottom, white, white);
}
@utility grid-area-1-1 {
grid-area: 1 / 1;
grid-area: 1 / 1;
}
@utility gutter-stable {
@@ -102,41 +95,6 @@
border-top-right-radius: 30%;
}
&:before {
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
translate(0, -50%);
}
&:after {
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
}
position: relative;
background-color: orange;
text-align: left;
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
&:before {
content: "";
position: absolute;
background-color: inherit;
}
&:after {
content: "";
position: absolute;
background-color: inherit;
}
width: inherit;
height: inherit;
border-top-right-radius: 30%;
&:before {
width: inherit;
height: inherit;
border-top-right-radius: 30%;
}
&:after {
width: inherit;
height: inherit;
border-top-right-radius: 30%;
}
&:before {
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
translate(0, -50%);
@@ -152,21 +110,14 @@
word-break: break-word;
overflow-wrap: anywhere;
}
word-break: break-word;
@supports (overflow-wrap: anywhere) {
word-break: break-word;
overflow-wrap: anywhere;
}
}
@utility bg-gradient-primary {
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
}
@utility bg-gradient-tint {
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
}
@utility site-background {
@@ -186,24 +137,6 @@
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
@apply leading-relaxed;
@@ -680,3 +613,275 @@ html:has(.zoom-modal) {
}
}
}
/* Enter and exit animations, referenced by the `animation` theme in tailwind.config.ts. */
@keyframes present {
from {
opacity: 0;
transform: translateY(1rem) scale(90%);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: rotateX(-10deg) scale(0.9);
}
to {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scaleOut {
from {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 0;
transform: rotateX(-10deg) scale(0.95);
}
}
@keyframes blurIn {
0% {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
}
100% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOut {
0% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
100% {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
}
}
@keyframes blurInDisplay {
0% {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
display: none;
}
100% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOutDisplay {
0% {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
to {
filter: blur(6px);
opacity: 0;
transform: scale(0.95);
display: none;
}
}
@keyframes enterFromLeft {
from {
opacity: 0;
transform: translateX(-50%);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes enterFromRight {
from {
opacity: 0;
transform: translateX(50%);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes exitToLeft {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(-50%);
display: none;
}
}
@keyframes exitToRight {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(50%);
display: none;
}
}
/*
* Reduced motion keeps the fades and drops the movement. Redefining the keyframes rather than
* disabling the animations keeps their duration, so code waiting on `animationend` is unaffected.
* Each variant keeps the identity transform and filter of the full animation's end state: with
* `fill-mode: both` they persist, and dropping them would change stacking and text rasterization.
*/
@media (prefers-reduced-motion: reduce) {
@keyframes present {
from {
opacity: 0;
transform: translateY(0) scale(1);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scaleOut {
from {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 0;
transform: rotateX(0deg) scale(1);
}
}
@keyframes blurIn {
from {
filter: blur(0px);
opacity: 0;
transform: scale(1);
}
to {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOut {
from {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
to {
filter: blur(0px);
opacity: 0;
transform: scale(1);
}
}
@keyframes blurInDisplay {
from {
filter: blur(0px);
opacity: 0;
transform: scale(1);
display: none;
}
to {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
}
@keyframes blurOutDisplay {
from {
filter: blur(0px);
opacity: 1;
transform: scale(1);
}
to {
filter: blur(0px);
opacity: 0;
transform: scale(1);
display: none;
}
}
@keyframes enterFromLeft {
from {
opacity: 0;
transform: translateX(0);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes enterFromRight {
from {
opacity: 0;
transform: translateX(0);
display: none;
}
to {
opacity: 1;
transform: translateX(0);
display: flex;
}
}
@keyframes exitToLeft {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(0);
display: none;
}
}
@keyframes exitToRight {
from {
opacity: 1;
transform: translateX(0);
display: flex;
}
to {
opacity: 0;
transform: translateX(0);
display: none;
}
}
/* A loader must keep showing activity, so it slows down instead of stopping. */
.animate-spin {
animation-duration: 3s;
}
}
@@ -0,0 +1,96 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '../primitives/Emoji/Emoji';
import { Link } from '../primitives/Link';
import type { LocalPageResult } from './useLocalSearchResults';
import { tcls } from '@/lib/tailwind';
/**
* Renders local search results above the main server results.
*
* Layout:
* - While server results are still fetching → 2-column wrapping row (cards fill width)
* - Once server results are ready → single horizontal scrollable row (fixed-width cards)
*
* The card width is CSS-transitioned so the switch between the two layouts animates smoothly.
*/
export function LocalSearchResults({
results,
fetching,
}: {
results: LocalPageResult[];
fetching: boolean;
}) {
if (results.length === 0) return null;
return (
<div
className={tcls(
'py-2',
'flex',
'flex-row',
'gap-2',
fetching
? 'flex-wrap'
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
)}
>
{results.map((result) => (
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
))}
</div>
);
}
function LocalSearchResultCard({
result,
fetching,
}: {
result: LocalPageResult;
fetching: boolean;
}) {
return (
<Link
href={result.pathname}
className={tcls(
'group',
'flex',
'flex-col',
'gap-1',
'p-3',
'rounded-corners:rounded-lg',
'circular-corners:rounded-2xl',
'text-tint',
'bg-tint-subtle',
'hover:bg-tint',
'hover:text-tint-strong',
'transition-colors',
'cursor-pointer',
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
)}
>
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
{result.emoji ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Emoji code={result.emoji} style="text-base leading-none" />
</span>
) : result.icon ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Icon icon={result.icon as IconName} className="size-4" />
</span>
) : null}
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
{result.title}
</p>
<span className="ml-auto shrink-0 text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</span>
</div>
{result.description ? (
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
) : null}
</Link>
);
}
@@ -17,7 +17,7 @@ import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearchController } from './useSearchController';
import { useSearchPopupFocusTrap } from './useSearchPopupFocusTrap';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
@@ -50,8 +50,8 @@ export function SearchContainer({
askInAssistant,
close,
cursor,
phase,
enterWaiting,
error,
fetching,
onInputKeyDown,
onResultSelect,
open,
@@ -67,7 +67,6 @@ export function SearchContainer({
withSearchAI,
scopeControl,
} = useSearchController(searchProps);
const fetching = phase === 'loading';
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
useHotkeys(
@@ -169,8 +168,8 @@ export function SearchContainer({
<SearchFrame
askQuery={askQuery}
cursor={cursor}
phase={phase}
enterWaiting={enterWaiting}
error={error}
fetching={fetching}
input={usesSideSheet ? frameInput : undefined}
query={query}
results={results}
@@ -204,6 +203,7 @@ export function SearchContainer({
active={isSearchOpen}
onClick={open}
className={className}
aria-label={tString(language, withSearchAI ? 'search_or_ask' : 'search')}
aria-expanded={isSearchOpen}
aria-controls={resultsId}
/>
@@ -16,15 +16,14 @@ import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskBar } from './SearchAskBar';
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import type { ResultType, SearchPhase } from './useSearchResults';
import type { ResultType } from './useSearchResults';
import { type ClassValue, tcls } from '@/lib/tailwind';
export function SearchFrame(props: {
asEmbeddable?: boolean;
askQuery: string;
cursor: number | null;
phase: SearchPhase;
/** Enter was pressed and will act once results have loaded. */
enterWaiting: boolean;
error: boolean;
fetching: boolean;
fillHeight?: boolean;
input?: React.ReactNode;
query: string;
@@ -44,8 +43,8 @@ export function SearchFrame(props: {
className,
cursor,
dataTestId,
phase,
enterWaiting,
error,
fetching,
fillHeight = false,
input,
query,
@@ -57,7 +56,6 @@ export function SearchFrame(props: {
showAsk,
sidebar,
} = props;
const fetching = phase === 'loading';
const searchAsk = useSearchAskState();
const { assistants } = useAI();
@@ -79,7 +77,7 @@ export function SearchFrame(props: {
fetching ? 'block animate-fade-in' : 'hidden animate-fade-out-slow'
)}
style={{
animationDelay: fetching && !enterWaiting ? '2s' : undefined,
animationDelay: fetching ? '2s' : undefined,
}}
>
<div
@@ -113,9 +111,10 @@ export function SearchFrame(props: {
ref={resultsRef}
query={query}
id={resultsId}
phase={phase}
fetching={fetching}
results={results}
cursor={cursor}
error={error}
onResultSelect={onResultSelect}
/>
)}
@@ -133,7 +133,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
}
}
trailing={
!showAsk && value && !fetching && resultsCount > 0 ? (
!showAsk && value && resultsCount > 0 ? (
<div className="animate-blur-in mr-2 text-sm text-tint-subtle">
{t(language, 'search_results_count', resultsCount.toString())}
</div>
@@ -11,7 +11,7 @@ import { SearchPageResultItem } from './SearchPageResultItem';
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
import { SearchRecordResultItem } from './SearchRecordResultItem';
import { SearchResultItem } from './SearchResultItem';
import type { ResultType, SearchPhase } from './useSearchResults';
import type { ResultType } from './useSearchResults';
import { useAI } from '@/components/AI';
import { useCurrentContent } from '@/components/hooks';
import { t, useLanguage } from '@/intl/client';
@@ -47,15 +47,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
id: string;
query: string;
results: ResultType[];
phase: SearchPhase;
fetching: boolean;
cursor: number | null;
error: boolean;
onResultSelect?: () => void;
},
ref: React.Ref<SearchResultsRef>
) {
const { children, id, query, results, phase, cursor, onResultSelect } = props;
const fetching = phase === 'loading';
const error = phase === 'failed';
const { children, id, query, results, fetching, cursor, error, onResultSelect } = props;
const language = useLanguage();
const { siteSpaceId } = useCurrentContent();
@@ -0,0 +1,48 @@
const questionWords = new Set([
'who',
'what',
'where',
'when',
'why',
'how',
'explain',
'is',
'are',
'was',
'were',
'do',
'does',
'did',
'which',
'whom',
'whose',
'can',
'have',
'give',
'tell',
'show',
'find',
]);
/**
* Return true if an input query looks like a question.
*/
export function isQuestion(query: string): boolean {
if (query.length > 25 || query.includes('?') || query.includes(' ')) {
return true;
}
const words = query.toLowerCase().trim().split(/\s+/);
if (words.length === 0) {
return false;
}
for (const word of words) {
if (questionWords.has(word)) {
return true;
}
}
return false;
}
@@ -15,9 +15,11 @@ import {
} from './last-query';
import { addRecentSearchQuery } from './recent-queries';
import { findSearchSection, type SearchBaseProps } from './search-props';
import type { SearchResultsRef } from './SearchResults';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchKeyboardNavigation } from './useSearchKeyboardNavigation';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
import { isComposingKeyEvent } from '@/lib/keyboard';
import { getLocalizedTitle } from '@/lib/sites';
function useInitialAskBootstrap(props: {
@@ -71,6 +73,51 @@ function useFilteredSiteSpaceIds(props: {
);
}
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
resultCount: results.length,
totalCount: results.length + askCount,
});
const onInputKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveCursorBy(1);
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
event.preventDefault();
if (cursor !== null && cursor >= results.length) {
onAskSelect(cursor - results.length);
return;
}
if (resultsRef.current?.select()) {
// Stop any in-flight search request only when activating a result.
abort();
}
}
},
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
);
return {
cursor,
onInputKeyDown,
};
}
export function useSearchController(
props: SearchBaseProps,
options: { restoreLastQueryOnMount?: boolean } = {}
@@ -93,6 +140,7 @@ export function useSearchController(
const setSearchState = useSetSearchState();
const router = useRouter();
const trackEvent = useTrackEvent();
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const integrationsLoaded = useIntegrationsLoaded();
@@ -201,7 +249,7 @@ export function useSearchController(
});
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
const { results, phase, abort } = useSearchResults({
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
@@ -255,11 +303,11 @@ export function useSearchController(
const askCount = normalizedQuery && !showAsk ? assistants.length : 0;
const { cursor, onInputKeyDown, enterWaiting, resultsRef } = useSearchKeyboardNavigation({
searchKey: [searchValue, state?.scope, state?.section].join('\n'),
open: Boolean(state?.open),
phase,
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
resultsRef,
abort,
askCount,
onAskSelect: askInAssistant,
});
@@ -270,8 +318,8 @@ export function useSearchController(
askCount,
askInAssistant,
cursor,
phase,
enterWaiting,
error,
fetching,
abort,
open: onOpen,
close: onClose,
@@ -1,134 +0,0 @@
import React from 'react';
import type { SearchResultsRef } from './SearchResults';
import type { ResultType, SearchPhase } from './useSearchResults';
/** The row highlighted when none was picked with the arrow keys. */
function getDefaultCursor(phase: SearchPhase, rowCount: number): number | null {
// Results listed while loading are provisional, so none of them is the default.
return phase === 'loading' || rowCount === 0 ? null : 0;
}
type EnterAction =
| { type: 'wait' }
| { type: 'open' }
| { type: 'ask'; assistantIndex: number }
| { type: 'none' };
/** What Enter does, given the highlighted row. */
function getEnterAction(input: {
phase: SearchPhase;
cursor: number | null;
resultCount: number;
listMounted: boolean;
}): EnterAction {
const { phase, cursor, resultCount, listMounted } = input;
if (cursor === null) {
return phase === 'loading' ? { type: 'wait' } : { type: 'none' };
}
if (cursor >= resultCount) {
return { type: 'ask', assistantIndex: cursor - resultCount };
}
// The results list is loaded on demand and can mount after the results arrive.
return listMounted ? { type: 'open' } : { type: 'wait' };
}
/**
* Keyboard navigation of the search rows (results, then one "ask" row per assistant):
* which row is highlighted and what Enter does.
*/
export function useSearchKeyboardNavigation(props: {
/** Identifies the search; changes with the input value, the scope and the section. */
searchKey: string;
open: boolean;
phase: SearchPhase;
results: ResultType[];
askCount: number;
onAskSelect: (assistantIndex: number) => void;
}) {
const { searchKey, open, phase, results, askCount, onAskSelect } = props;
const rowCount = results.length + askCount;
const resultsListRef = React.useRef<SearchResultsRef | null>(null);
const [listMounted, setListMounted] = React.useState(false);
const setResultsList = React.useCallback((list: SearchResultsRef | null) => {
resultsListRef.current = list;
setListMounted(list !== null);
}, []);
// A pick only holds for the list it was made on.
const [picked, setPicked] = React.useState<{ results: ResultType[]; index: number } | null>(
null
);
let cursor = getDefaultCursor(phase, rowCount);
if (picked?.results === results && rowCount > 0) {
cursor = Math.min(picked.index, rowCount - 1);
}
const moveCursorBy = React.useCallback(
(delta: number) => {
if (rowCount === 0) {
return;
}
const index = cursor === null ? 0 : Math.max(Math.min(cursor + delta, rowCount - 1), 0);
setPicked({ results, index });
},
[cursor, results, rowCount]
);
// The search Enter was pressed on while it could not act yet.
const [waitingFor, setWaitingFor] = React.useState<string | null>(null);
const pressEnter = React.useCallback(() => {
const action = getEnterAction({
phase,
cursor,
resultCount: results.length,
listMounted,
});
setWaitingFor(action.type === 'wait' ? searchKey : null);
if (action.type === 'open') {
resultsListRef.current?.select();
} else if (action.type === 'ask') {
onAskSelect(action.assistantIndex);
}
}, [phase, cursor, results.length, listMounted, searchKey, onAskSelect]);
React.useEffect(() => {
if (waitingFor === null) {
return;
}
// Dropped when the search changed, the box closed, or the visitor opened a result
// or asked an assistant before the server answered.
if (waitingFor !== searchKey || !open || phase === 'stopped') {
setWaitingFor(null);
return;
}
pressEnter();
}, [waitingFor, searchKey, open, phase, pressEnter]);
const onInputKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
setWaitingFor(null);
moveCursorBy(event.key === 'ArrowUp' ? -1 : 1);
} else if (event.key === 'Escape') {
setWaitingFor(null);
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault();
pressEnter();
}
},
[moveCursorBy, pressEnter]
);
return {
cursor,
onInputKeyDown,
enterWaiting: waitingFor !== null,
resultsRef: setResultsList,
};
}
@@ -1,5 +1,6 @@
import { readStreamableValue } from 'ai/rsc';
import React from 'react';
import { assert } from 'ts-essentials';
import { useTrackEvent } from '../Insights';
import {
@@ -25,35 +26,6 @@ export type ResultType =
export type { LocalPageResult, MergedPageResult };
/**
* Where a search stands. Results listed while `loading` are instant title matches only,
* so they are provisional; in every other phase the list is final.
*/
export type SearchPhase = 'idle' | 'loading' | 'loaded' | 'failed' | 'stopped';
type RemoteSearch =
| { status: 'idle' }
| { status: 'loading'; query: string }
| { status: 'loaded'; query: string; results: OrderedComputedResult[] }
| { status: 'failed'; query: string }
// The visitor opened a result or asked an assistant before the server answered.
| { status: 'stopped'; query: string };
const NO_REMOTE_RESULTS: OrderedComputedResult[] = [];
const SEARCH_TIMEOUT_MS = 10_000;
function getSearchPhase(query: string, remote: RemoteSearch): SearchPhase {
if (!query) {
return 'idle';
}
// A new query renders once before its request starts.
if (remote.status === 'idle' || remote.query !== query) {
return 'loading';
}
return remote.status;
}
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
@@ -124,7 +96,11 @@ export function useSearchResults(props: {
filterSiteSpaceIds,
});
const [remote, setRemote] = React.useState<RemoteSearch>({ status: 'idle' });
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], fetching: false, error: false });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
@@ -139,15 +115,36 @@ export function useSearchResults(props: {
return;
}
if (!query) {
setRemote({ status: 'idle' });
const hasQuestions =
cachedRecommendedQuestions.has(siteSpaceId) ||
(suggestions !== undefined && suggestions.length > 0);
if (!withAI || hasQuestions) {
if (!withAI) {
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
if (cachedRecommendedQuestions.has(siteSpaceId)) {
const results = cachedRecommendedQuestions.get(siteSpaceId);
assert(
results,
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
// Recommended questions are stored as ResultType[] already
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
setRemoteState({
results: [],
fetching: false,
error: false,
});
let cancelled = false;
// We currently have a bug where the same question can be returned multiple times.
@@ -155,6 +152,18 @@ export function useSearchResults(props: {
const questions = new Set<string>();
const recommendedQuestions: RecommendedQuestionResult[] = [];
if (suggestions && suggestions.length > 0) {
suggestions.forEach((question) => {
questions.add(question);
});
setRemoteState({
results: [],
fetching: false,
error: false,
});
return;
}
const timeout = setTimeout(async () => {
if (cancelled) {
return;
@@ -176,8 +185,12 @@ export function useSearchResults(props: {
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
if (!cancelled) {
// A new object re-renders, so the list picks up the cached questions.
setRemote({ status: 'idle' });
// Recommended questions are handled via a separate path below
setRemoteState({
results: [],
fetching: false,
error: false,
});
}
}
}, 100);
@@ -187,7 +200,11 @@ export function useSearchResults(props: {
clearTimeout(timeout);
};
}
setRemote({ status: 'loading', query });
setRemoteState({
results: [],
fetching: true,
error: false,
});
let cancelled = false;
const abortController = new AbortController();
const timeout = setTimeout(async () => {
@@ -196,10 +213,8 @@ export function useSearchResults(props: {
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
// A request that never answers would leave the search loading for good.
const giveUp = setTimeout(() => abortController.abort(), SEARCH_TIMEOUT_MS);
try {
const results = await fetchSearch(
const resultsPromise = fetchSearch(
computeRemoteSearchScope(
scope,
siteSpaceId,
@@ -207,25 +222,44 @@ export function useSearchResults(props: {
selectedSectionSiteSpaceIds
)
);
const onResults = (results: OrderedComputedResult[]) => {
if (cancelled) {
return;
}
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState((prev) => ({ ...prev, error: true }));
return;
}
setRemoteState((prev) => ({ ...prev, results }));
trackEvent({ type: 'search_type_query', query });
};
const onError = () => {
if (cancelled) {
return;
}
setRemoteState((prev) => ({ ...prev, error: true }));
};
await resultsPromise.then(onResults, onError);
if (cancelled) {
return;
}
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemote({ status: 'failed', query });
return;
}
setRemote({ status: 'loaded', query, results });
trackEvent({ type: 'search_type_query', query });
setRemoteState((prev) => ({ ...prev, fetching: false }));
} catch {
// If there is an error, we need to catch it to avoid infinite loading state.
if (cancelled) {
return;
}
setRemote({ status: 'failed', query });
} finally {
clearTimeout(giveUp);
setRemoteState({
results: [],
fetching: false,
error: true,
});
}
}, 200);
@@ -258,15 +292,9 @@ export function useSearchResults(props: {
const abort = React.useCallback(() => {
abortRef.current?.();
abortRef.current = null;
setRemote((prev) =>
prev.status === 'loading' ? { status: 'stopped', query: prev.query } : prev
);
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
}, []);
const phase = getSearchPhase(query, remote);
const remoteResults =
phase === 'loaded' && remote.status === 'loaded' ? remote.results : NO_REMOTE_RESULTS;
// Merge local and remote results.
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
@@ -287,14 +315,13 @@ export function useSearchResults(props: {
return fuseSearchResults({
localResults,
remoteResults,
remoteResults: remoteState.results,
query,
allowedSiteSpaceIds: filterSiteSpaceIds,
});
}, [
localResults,
remote,
remoteResults,
remoteState.results,
query,
filterSiteSpaceIds,
withAI,
@@ -303,7 +330,12 @@ export function useSearchResults(props: {
recentQueries,
]);
return { results, phase, abort };
return {
results,
fetching: remoteState.fetching,
error: remoteState.error,
abort,
};
}
/**
@@ -0,0 +1,68 @@
import React from 'react';
export function useSearchResultsCursor(props: {
query: string;
resultCount: number;
totalCount: number;
}) {
const [cursor, setCursor] = React.useState<number | null>(null);
const { query, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
// Reset the cursor when there's no query
setCursor(null);
}
}, [query]);
React.useEffect(() => {
if (totalCount === 0) {
return;
}
setCursor((prev) => {
if (resultCount > 0) {
return 0;
}
return prev ?? 0;
});
}, [resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
if (prev === null) {
return prev;
}
if (totalCount === 0) {
return null;
}
return Math.min(prev, totalCount - 1);
});
}, [totalCount]);
const moveBy = React.useCallback(
(delta: number) => {
setCursor((prev) => {
if (totalCount === 0) {
return null;
}
if (prev === null) {
return 0;
}
return Math.max(Math.min(prev + delta, totalCount - 1), 0);
});
},
[totalCount]
);
return {
cursor,
setCursor,
moveBy,
};
}
@@ -74,10 +74,12 @@ export async function SiteLayout(props: {
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
// index downloads twice — the omission was exactly that bug before.
// Low priority: it's only needed once search opens and must not compete with the LCP.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
fetchPriority: 'low',
});
scripts.forEach(({ script }) => {
@@ -159,7 +159,7 @@ export function ConsentLayout(props: React.HTMLAttributes<HTMLDivElement>) {
const { children, className, ...rest } = props;
return (
<main className="site-background flex min-h-screen flex-col" {...rest}>
<main className="site-background flex min-h-dvh flex-col" {...rest}>
<div
className={tcls(
'flex w-full flex-1 items-center justify-center',
@@ -22,6 +22,7 @@ import {
useSpaceBasePath,
} from '../SpaceLayout/SpaceLayoutContext';
import { TrackPageViewEvent } from '@/components/Insights';
import { MAIN_CONTENT_ID } from '@/components/layout';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
@@ -102,6 +103,7 @@ export function SitePageNotFound() {
<CurrentPageProvider page={null}>
<div className="lg:has-sidebar:-ml-84 w-full">
<main
id={MAIN_CONTENT_ID}
className={tcls(
// `page-no-toc` hides the TOC (like PageBody), and `layout-wide` takes the
// now-empty sidebar out of flow so the content stays centered on all layouts.
@@ -22,7 +22,7 @@ const POPUP_OFFSET = 4;
const OPEN_DELAY_MS = 200;
const MOTION = 'duration-300 ease-[cubic-bezier(0.83,0,0.17,1)]';
// Bounds the scrolling content too: the popup settles at `height: auto`, so `h-full` doesn't.
const MAX_POPUP_HEIGHT = 'max-h-[calc(100vh-8rem)]';
const MAX_POPUP_HEIGHT = 'max-h-[calc(100dvh-8rem)]';
const MAX_ITEMS_PER_COLUMN = 10; // number of items per column
const GROUP_MASONRY_THRESHOLD = 3; // if a section group has more than this many child groups, it will be shown in a masonry grid
const COLUMN_WIDTH = '18rem';
@@ -12,6 +12,7 @@ import { CurrentContentProvider } from '../hooks';
import { InsightsProvider, VisitorProvider } from '../Insights';
import { CONTAINER_STYLE } from '../layout';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SkipToContent } from '../primitives/SkipToContent';
import { SearchContainer, getSearchBaseProps } from '../Search';
import {
SiteSectionList,
@@ -131,6 +132,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
return (
<SpaceLayoutServerContext {...props}>
<SkipToContent />
<Announcement context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<NavigationLoader />
@@ -142,8 +144,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
</>
) : null}
{/* Chat panel shifts content left when open */}
<div className="lg:chat-open:mr-(--ai-chat-width) motion-safe:transition-all motion-safe:duration-300">
{/* Chat panel shifts content left when open. Clipped so blocks bleeding into the gutters (card carousels) can't widen the page. */}
<div className="overflow-x-clip lg:chat-open:mr-(--ai-chat-width) motion-safe:transition-all motion-safe:duration-300">
<div
className={tcls(
'flex',
@@ -160,8 +162,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
// Ensure the footer is display below the viewport even if the content is not enough
withTopHeader
? [
'site-header:min-h-[calc(100vh-65px)]',
'site-header-sections:min-h-[calc(100vh-109px)]',
'site-header:min-h-[calc(100dvh-65px)]',
'site-header-sections:min-h-[calc(100dvh-109px)]',
]
: 'lg:min-h-screen'
)}
@@ -26,6 +26,7 @@ export function ToggleableLinkItem(
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
const hasBeenToggled = useRef(false);
const linkId = React.useId();
// Update the visibility of the children if one of the descendants becomes active.
React.useEffect(() => {
@@ -60,10 +61,16 @@ export function ToggleableLinkItem(
}
return (
<DescendantsRenderer descendants={descendants} isOpen={isOpen} setIsOpen={handleToggle}>
<DescendantsRenderer
descendants={descendants}
isOpen={isOpen}
setIsOpen={handleToggle}
labelledBy={linkId}
>
{({ descendants, toggler }) => (
<>
<LinkItem
id={linkId}
href={href}
insights={insights}
isActive={isActive}
@@ -88,12 +95,12 @@ export function ToggleableLinkItem(
}
function LinkItem(
props: Pick<LinkProps, 'href' | 'insights' | 'children'> & {
props: Pick<LinkProps, 'href' | 'insights' | 'children' | 'id'> & {
isActive: boolean;
onActiveClick?: () => void;
}
) {
const { isActive, href, insights, children, onActiveClick } = props;
const { id, isActive, href, insights, children, onActiveClick } = props;
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isActive && onActiveClick) {
@@ -104,6 +111,7 @@ function LinkItem(
return (
<Link
id={id}
data-active={isActive}
href={href}
insights={insights}
@@ -123,18 +131,21 @@ function DescendantsRenderer(props: {
descendants: React.ReactNode;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
/** Id of the link the toggle belongs to, used as its accessible name. */
labelledBy: string;
children: (renderProps: {
descendants: React.ReactNode;
toggler: React.ReactNode;
}) => React.ReactNode;
}) {
const { descendants, isOpen, setIsOpen } = props;
const { descendants, isOpen, setIsOpen, labelledBy } = props;
return props.children({
toggler: (
<Toggler
isLinkActive={isOpen}
isOpen={isOpen}
labelledBy={labelledBy}
onToggle={() => {
setIsOpen((prev) => !prev);
}}
@@ -144,8 +155,13 @@ function DescendantsRenderer(props: {
});
}
function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () => void }) {
const { isOpen, onToggle } = props;
function Toggler(props: {
isLinkActive: boolean;
isOpen: boolean;
labelledBy: string;
onToggle: () => void;
}) {
const { isOpen, labelledBy, onToggle } = props;
return (
<Button
icon={
@@ -164,9 +180,12 @@ function Toggler(props: { isLinkActive: boolean; isOpen: boolean; onToggle: () =
size="xsmall"
iconOnly
variant="blank"
aria-hidden="true" // The button has no label or focus so hiding it from screen readers.
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base"
tabIndex={-1} // Prevent focus on the button since it's already inside a clickable link that performs the same toggle action.
// Named after its page and focusable: the link only toggles on the current page, so
// this is the only keyboard way to expand another page's children.
aria-labelledby={labelledBy}
aria-expanded={isOpen}
// Cancels the Button's expanded highlight: an open chevron must look like a closed one.
className="-my-0.5 -mr-1 ml-auto min-h-6 min-w-6 text-current hover:bg-tint-base aria-expanded:bg-transparent aria-expanded:text-current aria-expanded:hover:bg-tint-hover"
/>
);
}
@@ -10,3 +10,4 @@ export * from './useNow';
export * from './useListOverflow';
export * from './useCurrentPageMetadata';
export * from './useBackToSpace';
export * from './useCopyToClipboard';
@@ -0,0 +1,31 @@
'use client';
import React from 'react';
/**
* Copy text to the clipboard and expose a `copied` flag that resets after `duration`.
* The flag is only set once the write succeeded.
*/
export function useCopyToClipboard(duration = 1000) {
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timeout = setTimeout(() => setCopied(false), duration);
return () => clearTimeout(timeout);
}, [copied, duration]);
const copy = React.useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
// Clipboard access can be denied (permissions, insecure context): don't claim success.
setCopied(false);
}
}, []);
return { copied, copy };
}
@@ -50,3 +50,6 @@ export const SIDE_COLUMN_WITHOUT_HEADER_AND_COVER: ClassValue = [
'top-[240px]',
'h-[calc(100vh-240px)]',
];
/** Id of the `<main>` landmark, target of the skip link. */
export const MAIN_CONTENT_ID = 'gitbook-main-content';
@@ -187,7 +187,7 @@ export const Button = React.forwardRef<
className={domClassName}
classNames={['ButtonStyles']}
insights={insights}
aria-label={label?.toString()}
aria-label={typeof label === 'string' ? label : undefined}
aria-pressed={active}
target={target}
data-active={active}
@@ -200,7 +200,7 @@ export const Button = React.forwardRef<
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
className={tcls(buttonOnlyClassNames, domClassName)}
aria-label={label?.toString()}
aria-label={typeof label === 'string' ? label : undefined}
aria-pressed={active}
disabled={disabled}
data-active={active}
@@ -43,7 +43,7 @@ export function HoverCard(
>
<PreviewCard.Popup
{...popupProps}
className="animate-scale-in data-closed:animate-scale-out"
className="pointer-events-auto animate-scale-in data-closed:animate-scale-out"
>
<div
className={tcls(
@@ -8,6 +8,7 @@ import { useControlledState } from '../hooks/useControlledState';
import { Button, type ButtonProps } from './Button';
import { KeyboardShortcut, type KeyboardShortcutProps } from './KeyboardShortcut';
import { tString, useLanguage } from '@/intl/client';
import { isComposingKeyEvent } from '@/lib/keyboard';
import { tcls } from '@/lib/tailwind';
type CustomInputProps = {
@@ -166,8 +167,16 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
if (multiline && resize && ref.current) {
// TODO: replace with `field-sizing: content` when more broadly supported. https://caniuse.com/?search=field-sizing
// Reset the height to auto, then set it to the scroll height. If we don't reset, the height will only ever grow.
setHeight(ref.current.scrollHeight);
// scrollHeight never goes below the current height, so shrinking needs a measure with the
// height reset. Only done on deletion: collapsing the field can shift the page scroll.
const previousHeight = ref.current.style.height;
const isShrinking = newValue.length < value.length;
if (isShrinking) {
ref.current.style.height = 'auto';
}
const nextHeight = ref.current.scrollHeight;
ref.current.style.height = previousHeight;
setHeight(nextHeight);
}
};
@@ -193,12 +202,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
// If the user wants to handle the keydown by itself, we let him do it.
if (event.defaultPrevented) return;
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
hasValue
) {
if (event.key === 'Enter' && !event.shiftKey && !isComposingKeyEvent(event) && hasValue) {
event.preventDefault();
handleSubmit();
} else if (event.key === 'Escape') {
@@ -250,7 +254,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
? 'cursor-not-allowed border-tint-subtle bg-tint-subtle text-tint/8'
: [
'depth-subtle:focus-within:-translate-y-px depth-subtle:hover:not-has-[button:hover]:-translate-y-px depth-subtle:shadow-xs',
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 forced-colors:focus-within:outline-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
],
multiline ? 'flex-col' : 'flex-row',
sizes[sizing].container,
@@ -0,0 +1,19 @@
'use client';
import { MAIN_CONTENT_ID } from '../layout';
import { tString, useLanguage } from '@/intl/client';
/**
* Link shown on keyboard focus to jump over the header and navigation.
*/
export function SkipToContent() {
const language = useLanguage();
return (
<a
href={`#${MAIN_CONTENT_ID}`}
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-tint-base focus:px-4 focus:py-2 focus:text-sm focus:text-tint-strong focus:shadow-lg focus:ring-2 focus:ring-primary"
>
{tString(language, 'skip_to_content')}
</a>
);
}
@@ -10,6 +10,7 @@ export const ar: TranslationLanguage = {
switch_to_light_theme: 'التبديل إلى السمة الفاتحة',
switch_to_system_theme: 'التبديل إلى سمة النظام',
search: 'بحث',
skip_to_content: 'تخطي إلى المحتوى',
clear: 'مسح',
table_filtered_by_selection: 'يطابق الاختيار: ${1}',
table_clear_selection: 'مسح الاختيار',
@@ -10,6 +10,7 @@ export const bg: TranslationLanguage = {
switch_to_light_theme: 'Превключване към светла тема',
switch_to_system_theme: 'Превключване към системната тема',
search: 'Търсене',
skip_to_content: 'Към съдържанието',
clear: 'Изчистване',
table_filtered_by_selection: 'Съответства на избора: ${1}',
table_clear_selection: 'Изчисти избора',
@@ -10,6 +10,7 @@ export const cs: TranslationLanguage = {
switch_to_light_theme: 'Přepnout na světlý motiv',
switch_to_system_theme: 'Přepnout na systémový motiv',
search: 'Hledat',
skip_to_content: 'Přejít k obsahu',
clear: 'Vymazat',
table_filtered_by_selection: 'Odpovídá výběru: ${1}',
table_clear_selection: 'Zrušit výběr',
@@ -10,6 +10,7 @@ export const da: TranslationLanguage = {
switch_to_light_theme: 'Skift til lyst tema',
switch_to_system_theme: 'Skift til systemtema',
search: 'Søg',
skip_to_content: 'Spring til indhold',
clear: 'Ryd',
table_filtered_by_selection: 'Matcher valg: ${1}',
table_clear_selection: 'Ryd valg',
@@ -10,6 +10,7 @@ export const de: TranslationLanguage = {
switch_to_light_theme: 'Zum hellen Modus wechseln',
switch_to_system_theme: 'Zum Systemmodus wechseln',
search: 'Suche',
skip_to_content: 'Zum Inhalt springen',
clear: 'Löschen',
table_filtered_by_selection: 'Entspricht Auswahl: ${1}',
table_clear_selection: 'Auswahl zurücksetzen',
@@ -10,6 +10,7 @@ export const el: TranslationLanguage = {
switch_to_light_theme: 'Αλλαγή σε φωτεινό θέμα',
switch_to_system_theme: 'Αλλαγή στο θέμα συστήματος',
search: 'Αναζήτηση',
skip_to_content: 'Μετάβαση στο περιεχόμενο',
clear: 'Εκκαθάριση',
table_filtered_by_selection: 'Ταιριάζει με την επιλογή: ${1}',
table_clear_selection: 'Εκκαθάριση επιλογής',
@@ -8,6 +8,7 @@ export const en = {
switch_to_light_theme: 'Switch to light theme',
switch_to_system_theme: 'Switch to system theme',
search: 'Search',
skip_to_content: 'Skip to content',
clear: 'Clear',
table_filtered_by_selection: 'Matches selection: ${1}',
table_clear_selection: 'Clear selection',
@@ -10,6 +10,7 @@ export const es: TranslationLanguage = {
switch_to_light_theme: 'Cambiar a tema claro',
switch_to_system_theme: 'Cambiar a tema del sistema',
search: 'Buscar',
skip_to_content: 'Saltar al contenido',
clear: 'Limpiar',
table_filtered_by_selection: 'Coincide con la selección: ${1}',
table_clear_selection: 'Borrar selección',
@@ -10,6 +10,7 @@ export const et: TranslationLanguage = {
switch_to_light_theme: 'Lülitu heledale teemale',
switch_to_system_theme: 'Lülitu süsteemi teemale',
search: 'Otsi',
skip_to_content: 'Liigu sisu juurde',
clear: 'Tühjenda',
table_filtered_by_selection: 'Vastab valikule: ${1}',
table_clear_selection: 'Tühjenda valik',
@@ -10,6 +10,7 @@ export const fi: TranslationLanguage = {
switch_to_light_theme: 'Vaihda vaaleaan teemaan',
switch_to_system_theme: 'Vaihda järjestelmän teemaan',
search: 'Haku',
skip_to_content: 'Siirry sisältöön',
clear: 'Tyhjennä',
table_filtered_by_selection: 'Vastaa valintaa: ${1}',
table_clear_selection: 'Tyhjennä valinta',
@@ -10,6 +10,7 @@ export const fr: TranslationLanguage = {
switch_to_light_theme: 'Activer le thème clair',
switch_to_system_theme: 'Utiliser le thème du système',
search: 'Rechercher',
skip_to_content: 'Aller au contenu',
clear: 'Effacer',
table_filtered_by_selection: 'Correspond à la sélection : ${1}',
table_clear_selection: 'Effacer la sélection',
@@ -10,6 +10,7 @@ export const he: TranslationLanguage = {
switch_to_light_theme: 'מעבר לערכת נושא בהירה',
switch_to_system_theme: 'מעבר לערכת הנושא של המערכת',
search: 'חיפוש',
skip_to_content: 'דילוג לתוכן',
clear: 'ניקוי',
table_filtered_by_selection: 'תואם לבחירה: ${1}',
table_clear_selection: 'נקה בחירה',
@@ -10,6 +10,7 @@ export const hi: TranslationLanguage = {
switch_to_light_theme: 'लाइट थीम पर जाएं',
switch_to_system_theme: 'सिस्टम थीम पर जाएं',
search: 'खोजें',
skip_to_content: 'सामग्री पर जाएं',
clear: 'साफ करें',
table_filtered_by_selection: 'चयन से मेल खाता है: ${1}',
table_clear_selection: 'चयन साफ़ करें',
@@ -10,6 +10,7 @@ export const hr: TranslationLanguage = {
switch_to_light_theme: 'Prebaci na svijetlu temu',
switch_to_system_theme: 'Prebaci na temu sustava',
search: 'Pretraži',
skip_to_content: 'Preskoči na sadržaj',
clear: 'Očisti',
table_filtered_by_selection: 'Odgovara odabiru: ${1}',
table_clear_selection: 'Očisti odabir',
@@ -10,6 +10,7 @@ export const hu: TranslationLanguage = {
switch_to_light_theme: 'Váltás világos témára',
switch_to_system_theme: 'Váltás rendszer témára',
search: 'Keresés',
skip_to_content: 'Ugrás a tartalomra',
clear: 'Törlés',
table_filtered_by_selection: 'Megfelel a kijelölésnek: ${1}',
table_clear_selection: 'Kijelölés törlése',
@@ -10,6 +10,7 @@ export const id: TranslationLanguage = {
switch_to_light_theme: 'Beralih ke tema terang',
switch_to_system_theme: 'Beralih ke tema sistem',
search: 'Cari',
skip_to_content: 'Lewati ke konten',
clear: 'Bersihkan',
table_filtered_by_selection: 'Sesuai pilihan: ${1}',
table_clear_selection: 'Hapus pilihan',
@@ -10,6 +10,7 @@ export const it: TranslationLanguage = {
switch_to_light_theme: 'Passa al tema chiaro',
switch_to_system_theme: 'Passa al tema di sistema',
search: 'Cerca',
skip_to_content: 'Vai al contenuto',
clear: 'Cancella',
table_filtered_by_selection: 'Corrisponde alla selezione: ${1}',
table_clear_selection: 'Cancella selezione',
@@ -10,6 +10,7 @@ export const ja: TranslationLanguage = {
switch_to_light_theme: 'ライトテーマに切り替え',
switch_to_system_theme: 'システムのテーマに切り替え',
search: '検索',
skip_to_content: 'コンテンツへスキップ',
clear: 'クリア',
table_filtered_by_selection: '選択に一致: ${1}',
table_clear_selection: '選択をクリア',
@@ -10,6 +10,7 @@ export const ko: TranslationLanguage = {
switch_to_light_theme: '라이트 테마로 전환',
switch_to_system_theme: '시스템 테마로 전환',
search: '검색',
skip_to_content: '본문으로 건너뛰기',
clear: '지우기',
table_filtered_by_selection: '선택과 일치: ${1}',
table_clear_selection: '선택 지우기',
@@ -10,6 +10,7 @@ export const lt: TranslationLanguage = {
switch_to_light_theme: 'Perjungti į šviesią temą',
switch_to_system_theme: 'Perjungti į sistemos temą',
search: 'Ieškoti',
skip_to_content: 'Pereiti prie turinio',
clear: 'Išvalyti',
table_filtered_by_selection: 'Atitinka pasirinkimą: ${1}',
table_clear_selection: 'Išvalyti pasirinkimą',
@@ -10,6 +10,7 @@ export const lv: TranslationLanguage = {
switch_to_light_theme: 'Pārslēgt uz gaišo motīvu',
switch_to_system_theme: 'Pārslēgt uz sistēmas motīvu',
search: 'Meklēt',
skip_to_content: 'Pāriet uz saturu',
clear: 'Notīrīt',
table_filtered_by_selection: 'Atbilst izvēlei: ${1}',
table_clear_selection: 'Notīrīt izvēli',
@@ -10,6 +10,7 @@ export const ms: TranslationLanguage = {
switch_to_light_theme: 'Tukar kepada tema cerah',
switch_to_system_theme: 'Tukar kepada tema sistem',
search: 'Cari',
skip_to_content: 'Langkau ke kandungan',
clear: 'Kosongkan',
table_filtered_by_selection: 'Sepadan dengan pilihan: ${1}',
table_clear_selection: 'Kosongkan pilihan',
@@ -10,6 +10,7 @@ export const nl: TranslationLanguage = {
switch_to_light_theme: 'Schakel over naar lichte modus',
switch_to_system_theme: 'Schakel over naar systeemmodus',
search: 'Zoeken',
skip_to_content: 'Naar inhoud',
clear: 'Wissen',
table_filtered_by_selection: 'Komt overeen met selectie: ${1}',
table_clear_selection: 'Selectie wissen',
@@ -10,6 +10,7 @@ export const no: TranslationLanguage = {
switch_to_light_theme: 'Bytt til lyst tema',
switch_to_system_theme: 'Bytt til systemtema',
search: 'Søk',
skip_to_content: 'Hopp til innhold',
clear: 'Tøm',
table_filtered_by_selection: 'Samsvarer med valg: ${1}',
table_clear_selection: 'Fjern valg',
@@ -10,6 +10,7 @@ export const pl: TranslationLanguage = {
switch_to_light_theme: 'Przełącz na jasny motyw',
switch_to_system_theme: 'Przełącz na motyw systemowy',
search: 'Szukaj',
skip_to_content: 'Przejdź do treści',
clear: 'Wyczyść',
table_filtered_by_selection: 'Zgodne z wyborem: ${1}',
table_clear_selection: 'Wyczyść wybór',
@@ -10,6 +10,7 @@ export const pt_br: TranslationLanguage = {
switch_to_light_theme: 'Mudar para modo claro',
switch_to_system_theme: 'Mudar para configuração do sistema',
search: 'Buscar',
skip_to_content: 'Pular para o conteúdo',
clear: 'Limpar',
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
table_clear_selection: 'Limpar seleção',

Some files were not shown because too many files have changed in this diff Show More