Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] f6802f5e8b Version Packages 2026-11-12 17:24:24 +00:00
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
94 changed files with 808 additions and 316 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
+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
+5 -5
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",
},
},
@@ -49,7 +49,7 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.7.0",
"version": "0.7.1",
"dependencies": {
"@gitbook/api": "catalog:",
"bidc": "catalog:",
@@ -109,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.28.2",
"version": "0.28.3",
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
@@ -227,7 +227,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.5.0",
"version": "0.5.1",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -265,7 +265,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.18",
"version": "0.7.19",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/embed
## 0.7.1
### Patch Changes
- 65210b7: 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.7.0
### Minor Changes
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.7.0",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@gitbook/api": "catalog:",
+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);
+19
View File
@@ -1,5 +1,24 @@
# gitbook
## 0.28.3
### Patch Changes
- 0c3a51e: Fix horizontal page scroll caused by card carousels bleeding past the viewport.
- 5997a66: Remove duplicated declarations from the global stylesheet.
- 265ca4b: Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
- 9b5b8c8: Load the OpenAPI stylesheet only on pages with OpenAPI blocks.
- 19660ed: 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.
- 19660ed: Fix the search button accessible name, hoverable link previews, IME-safe Enter on Safari, cookie banner semantics, active outline item announcement and textarea shrinking.
- 65210b7: 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.
- 3e86382: Send only the site origin as referrer to third parties, and only let the embedding page open the embed messaging channel.
- 7d1cd9f: Remove the ChatGPT-specific plain text response for Markdown and llms.txt routes; these now always serve `text/markdown`.
- Updated dependencies [265ca4b]
- Updated dependencies [65210b7]
- @gitbook/icons@0.5.1
- @gitbook/embed@0.7.1
- @gitbook/react-contentkit@0.7.19
## 0.28.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.28.2",
"version": "0.28.3",
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
@@ -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.
*/
@@ -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;
}
}
@@ -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), {
@@ -203,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}
/>
@@ -19,6 +19,7 @@ import type { SearchResultsRef } from './SearchResults';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
import { isComposingKeyEvent } from '@/lib/keyboard';
import { getLocalizedTitle } from '@/lib/sites';
function useInitialAskBootstrap(props: {
@@ -95,7 +96,7 @@ function useSearchKeyboardNavigation(props: {
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveCursorBy(1);
} else if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
event.preventDefault();
if (cursor !== null && cursor >= results.length) {
onAskSelect(cursor - results.length);
@@ -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',
@@ -10,6 +10,7 @@ export const pt: TranslationLanguage = {
switch_to_light_theme: 'Mudar para o tema claro',
switch_to_system_theme: 'Mudar para o tema do sistema',
search: 'Pesquisar',
skip_to_content: 'Saltar para o conteúdo',
clear: 'Limpar',
table_filtered_by_selection: 'Corresponde à seleção: ${1}',
table_clear_selection: 'Limpar seleção',
@@ -10,6 +10,7 @@ export const ro: TranslationLanguage = {
switch_to_light_theme: 'Comută la tema luminoasă',
switch_to_system_theme: 'Comută la tema sistemului',
search: 'Caută',
skip_to_content: 'Salt la conținut',
clear: 'Șterge',
table_filtered_by_selection: 'Se potrivește cu selecția: ${1}',
table_clear_selection: 'Șterge selecția',
@@ -10,6 +10,7 @@ export const ru: 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 sk: TranslationLanguage = {
switch_to_light_theme: 'Prepnúť na svetlý motív',
switch_to_system_theme: 'Prepnúť na systémový motív',
search: 'Hľadať',
skip_to_content: 'Preskočiť na obsah',
clear: 'Vymazať',
table_filtered_by_selection: 'Zodpovedá výberu: ${1}',
table_clear_selection: 'Zrušiť výber',
@@ -10,6 +10,7 @@ export const sl: TranslationLanguage = {
switch_to_light_theme: 'Preklopi na svetlo temo',
switch_to_system_theme: 'Preklopi na sistemsko temo',
search: 'Išči',
skip_to_content: 'Preskoči na vsebino',
clear: 'Počisti',
table_filtered_by_selection: 'Ustreza izbiri: ${1}',
table_clear_selection: 'Počisti izbiro',
@@ -10,6 +10,7 @@ export const sv: TranslationLanguage = {
switch_to_light_theme: 'Byt till ljust tema',
switch_to_system_theme: 'Byt till systemtema',
search: 'Sök',
skip_to_content: 'Hoppa till innehåll',
clear: 'Rensa',
table_filtered_by_selection: 'Matchar val: ${1}',
table_clear_selection: 'Rensa val',
@@ -10,6 +10,7 @@ export const th: 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 tr: TranslationLanguage = {
switch_to_light_theme: 'Açık temaya geç',
switch_to_system_theme: 'Sistem temasına geç',
search: 'Ara',
skip_to_content: 'İçeriğe geç',
clear: 'Temizle',
table_filtered_by_selection: 'Seçimle eşleşiyor: ${1}',
table_clear_selection: 'Seçimi temizle',
@@ -10,6 +10,7 @@ export const uk: 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 vi: TranslationLanguage = {
switch_to_light_theme: 'Chuyển sang giao diện sáng',
switch_to_system_theme: 'Chuyển sang giao diện hệ thống',
search: 'Tìm kiếm',
skip_to_content: 'Chuyển đến nội dung',
clear: 'Xóa',
table_filtered_by_selection: 'Khớp với lựa chọn: ${1}',
table_clear_selection: 'Xóa lựa chọn',
@@ -10,6 +10,7 @@ export const yue: 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 zh_tw: 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 zh: TranslationLanguage = {
switch_to_light_theme: '切换到浅色主题',
switch_to_system_theme: '切换到系统主题',
search: '搜索',
skip_to_content: '跳转到内容',
clear: '清除',
table_filtered_by_selection: '符合选择:${1}',
table_clear_selection: '清除选择',
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { isComposingKeyEvent } from './keyboard';
describe('isComposingKeyEvent', () => {
it('is false for a regular Enter', () => {
expect(isComposingKeyEvent({ nativeEvent: { isComposing: false, keyCode: 13 } })).toBe(
false
);
});
it('is true while composing', () => {
expect(isComposingKeyEvent({ nativeEvent: { isComposing: true, keyCode: 13 } })).toBe(true);
});
it('is true for the Safari confirming keydown', () => {
expect(isComposingKeyEvent({ nativeEvent: { isComposing: false, keyCode: 229 } })).toBe(
true
);
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Whether a keydown belongs to an in-progress IME composition and must not trigger a submit.
*/
export function isComposingKeyEvent(event: {
nativeEvent: Pick<KeyboardEvent, 'isComposing' | 'keyCode'>;
}): boolean {
// Safari fires `compositionend` before the confirming keydown, so `isComposing` is already
// false there; keyCode 229 is the only remaining signal.
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229;
}
+1 -1
View File
@@ -590,7 +590,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
response.headers.set('content-security-policy', getContentSecurityPolicy());
// Basic security headers
response.headers.set('strict-transport-security', 'max-age=31536000');
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
response.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
response.headers.set('x-content-type-options', 'nosniff');
// Debug header
response.headers.set('x-gitbook-route-type', routeType);
+1 -68
View File
@@ -341,6 +341,7 @@ const config: Config = {
heightOut: 'heightOut 500ms ease both',
crawl: 'crawl 2s ease-in-out infinite',
},
// The enter/exit keyframes live in globals.css, next to their reduced-motion variants.
keyframes: {
bounceSmall: {
'0%, 100%': {
@@ -374,16 +375,6 @@ const config: Config = {
},
},
present: {
from: {
opacity: '0',
transform: 'translateY(1rem) scale(90%)',
},
to: {
opacity: '1',
transform: 'translateY(0) scale(1)',
},
},
rotateLoop: {
'0%': {
transform: 'rotate(0deg) scaleY(2.5) scaleX(2.5)',
@@ -477,64 +468,6 @@ const config: Config = {
transform: 'translateY(-10%)',
},
},
enterFromRight: {
from: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'flex' },
},
enterFromLeft: {
from: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
to: { opacity: '1', transform: 'translateX(0)', display: 'flex' },
},
exitToRight: {
from: { opacity: '1', transform: 'translateX(0)', display: 'flex' },
to: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
},
exitToLeft: {
from: { opacity: '1', transform: 'translateX(0)', display: 'flex' },
to: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
},
scaleIn: {
from: { opacity: '0', transform: 'rotateX(-10deg) scale(0.9)' },
to: { opacity: '1', transform: 'rotateX(0deg) scale(1)' },
},
scaleOut: {
from: { opacity: '1', transform: 'rotateX(0deg) scale(1)' },
to: { opacity: '0', transform: 'rotateX(-10deg) scale(0.95)' },
},
blurIn: {
'0%': { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
'100%': { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
},
blurOut: {
'0%': { filter: 'blur(0px)', opacity: '1', transform: 'scale(1)' },
'100%': { filter: 'blur(6px)', opacity: '0', transform: 'scale(0.95)' },
},
blurInDisplay: {
'0%': {
filter: 'blur(6px)',
opacity: '0',
transform: 'scale(0.95)',
display: 'none',
},
'100%': {
filter: 'blur(0px)',
opacity: '1',
transform: 'scale(1)',
},
},
blurOutDisplay: {
'0%': {
filter: 'blur(0px)',
opacity: '1',
transform: 'scale(1)',
},
to: {
filter: 'blur(6px)',
opacity: '0',
transform: 'scale(0.95)',
display: 'none',
},
},
fadeOut: {
from: { opacity: '1' },
to: { opacity: '0' },
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/icons
## 0.5.1
### Patch Changes
- 265ca4b: Add a skip link, keyboard navigation between content tabs, focusable table of contents toggles, and hide decorative icons from assistive technologies.
## 0.5.0
### Minor Changes
+1 -1
View File
@@ -34,7 +34,7 @@
}
},
"sideEffects": false,
"version": "0.5.0",
"version": "0.5.1",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@gitbook/fontawesome-pro": "1.0.23"
+4 -1
View File
@@ -53,11 +53,14 @@ export const Icon = React.forwardRef(function Icon(
const [iconStyle, icon] = getIconStyle(propIconStyle, propIcon);
const source = context.iconSources?.[getInlineIconSourceKey(iconStyle, icon)];
const maskId = React.useId();
// Icons are decorative unless the caller names them; the control they sit in carries the name.
const isDecorative = !rest['aria-label'] && !rest['aria-labelledby'] && !rest.role;
if (source) {
return (
<svg
ref={ref}
aria-hidden={isDecorative ? true : undefined}
{...rest}
viewBox={source.viewBox}
fill="currentColor"
@@ -77,6 +80,7 @@ export const Icon = React.forwardRef(function Icon(
return (
<svg
ref={ref}
aria-hidden={isDecorative ? true : undefined}
{...rest}
style={{
...(size ? { width: size, height: size } : {}),
@@ -84,7 +88,6 @@ export const Icon = React.forwardRef(function Icon(
}}
className={`gb-icon ${className}`}
>
<title>{icon}</title>
<defs>
<mask
id={maskId}
+7
View File
@@ -1,5 +1,12 @@
# @gitbook/react-contentkit
## 0.7.19
### Patch Changes
- Updated dependencies [265ca4b]
- @gitbook/icons@0.5.1
## 0.7.18
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@gitbook/react-contentkit",
"version": "0.7.18",
"version": "0.7.19",
"type": "module",
"exports": {
".": {