diff --git a/.changeset/assistant-chat-aria-busy.md b/.changeset/assistant-chat-aria-busy.md new file mode 100644 index 000000000..fc563d777 --- /dev/null +++ b/.changeset/assistant-chat-aria-busy.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state. diff --git a/.changeset/assistant-form-overflow.md b/.changeset/assistant-form-overflow.md new file mode 100644 index 000000000..d86d7b567 --- /dev/null +++ b/.changeset/assistant-form-overflow.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text. diff --git a/.changeset/cookieless-integration-content-host.md b/.changeset/cookieless-integration-content-host.md new file mode 100644 index 000000000..0ffc8ecdf --- /dev/null +++ b/.changeset/cookieless-integration-content-host.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured. diff --git a/.changeset/dvorak-shortcut-logical-key.md b/.changeset/dvorak-shortcut-logical-key.md new file mode 100644 index 000000000..ddbf72db0 --- /dev/null +++ b/.changeset/dvorak-shortcut-logical-key.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts). diff --git a/.changeset/hint-wide-content-overflow.md b/.changeset/hint-wide-content-overflow.md new file mode 100644 index 000000000..73f1956a7 --- /dev/null +++ b/.changeset/hint-wide-content-overflow.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them. diff --git a/.changeset/link-tooltip-external-button.md b/.changeset/link-tooltip-external-button.md new file mode 100644 index 000000000..52bcf63b8 --- /dev/null +++ b/.changeset/link-tooltip-external-button.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive. diff --git a/.changeset/page-actions-items.md b/.changeset/page-actions-items.md new file mode 100644 index 000000000..b3d185ea2 --- /dev/null +++ b/.changeset/page-actions-items.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action diff --git a/.changeset/page-reference-chip.md b/.changeset/page-reference-chip.md new file mode 100644 index 000000000..0ad093c06 --- /dev/null +++ b/.changeset/page-reference-chip.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on. diff --git a/.changeset/rich-chefs-dig.md b/.changeset/rich-chefs-dig.md new file mode 100644 index 000000000..1490e5859 --- /dev/null +++ b/.changeset/rich-chefs-dig.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Support OpenAPI one-operation-per-page mode diff --git a/.changeset/table-block-search.md b/.changeset/table-block-search.md new file mode 100644 index 000000000..b25d7a82f --- /dev/null +++ b/.changeset/table-block-search.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a client-side search field to table blocks, with per-column filters for select and checkbox columns diff --git a/.changeset/tiny-items-talk.md b/.changeset/tiny-items-talk.md new file mode 100644 index 000000000..30c196306 --- /dev/null +++ b/.changeset/tiny-items-talk.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-openapi": patch +--- + +Handle circular oneOf with discriminator and allOf in OpenAPI schemas diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 8ade51001..8e5ee4823 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -55,6 +55,7 @@ runs: GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST + GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index 163a847fb..bb7b81750 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -57,6 +57,7 @@ runs: GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST + GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE diff --git a/bun.lock b/bun.lock index a01983909..c8a3208bf 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.15", + "turbo": "^2.9.18", "vercel": "50.37.3", }, }, @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.183.0", + "@gitbook/api": "0.184.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -756,7 +756,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.183.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-0+6VyRH7me5AtzU+mwZVFHDaJudtnHYEcxwQ/qO7p2swj7dOe/dOevfaphgx+pUvhXAg7VVlihETaAW/WsLTzQ=="], + "@gitbook/api": ["@gitbook/api@0.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 3ba6b2a99..f2a7592a7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.183.0", + "@gitbook/api": "0.184.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 45f3abf2e..cb856294b 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -31,12 +31,24 @@ import { headerLinks, runTestCases, setTimeToMorning, + waitForAIChatResponse, waitForCookiesDialog, waitForCoverImages, waitForNotFound, } from './util'; -const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`; +// Kept as deterministic as possible to reduce visual flakiness: no preamble, a +// single fixed search, a concise answer, and a fixed number of follow-ups. The +// model is never perfectly deterministic, so `overrideAIResponse` still +// normalizes the rendered content below — this prompt just narrows the variance. +const AI_PROMPT = [ + "You're being invoked by the GitBook CI/CD pipeline for automated visual testing.", + 'Follow these instructions exactly and do not deviate:', + '1. Do not write any preamble, commentary, or reasoning before acting.', + '2. Perform a single search for exactly "Lorem ipsum".', + '3. Reply with only the first sentence of the first page you find, and nothing else.', + '4. Always end by proposing exactly 3 follow-up suggestions.', +].join('\n'); const overrideAIInitialState = () => { const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]'); @@ -44,21 +56,45 @@ const overrideAIInitialState = () => { greeting.textContent = 'Good morning'; } }; + +/** + * Normalize the non-deterministic content of an AI response before screenshotting, + * while preserving the surrounding structure (message bubbles, tool/activity + * summary, response container, suggestion buttons) so visual regressions in the + * chat chrome are still caught. The actual answer formatting is covered separately + * by the deterministic page tests, since the AI response renders through the same + * `DocumentView`. + * + * Must run only once the chat is no longer `aria-busy` (the response has fully + * settled), otherwise React re-renders from late stream events will clobber these + * mutations. See `waitForAIChatResponse`. + */ const overrideAIResponse = () => { - const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]'); - if (userMessage) { + // The user's prompt varies in length; pin it to a fixed string. + document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => { userMessage.textContent = '[Replaced message] Chat message sent by the user'; - } - const assistantMessage = document.querySelectorAll( - '[data-testid="ai-chat-message-assistant"] .ai-response-document' - ); - assistantMessage.forEach((message) => { - message.innerHTML = '[Replaced message] AI chat response'; }); - const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]'); - suggestions.forEach((suggestion) => { - suggestion.textContent = 'Follow-up suggestion'; + + // The assistant's answer text is non-deterministic; replace the rendered + // document body while keeping the `.ai-response-document` container. + document + .querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document') + .forEach((message) => { + message.innerHTML = '
[Replaced message] AI chat response
'; + }); + + // The "Explored with N tools" activity label varies with the number of tool + // calls; pin it (the chevron sibling is left intact). + document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => { + summary.textContent = 'Explored'; }); + + // Follow-up suggestion text varies; pin each label. + document + .querySelectorAll('[data-testid="ai-chat-followup-suggestion"]') + .forEach((suggestion) => { + suggestion.textContent = 'Follow-up suggestion'; + }); }; const searchTestCases: Test[] = [ @@ -167,12 +203,13 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT); await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible(); - await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, { - timeout: 60_000, - }); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIResponse); + // Wait for the full response (incl. follow-up suggestions) to settle before + // asserting/screenshotting, rather than racing a fixed suggestion count. + await waitForAIChatResponse(page); + await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible(); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse), }, { name: 'Ask - AI Mode: Assistant - Keyboard shortcut', @@ -186,9 +223,9 @@ const searchTestCases: Test[] = [ await page.keyboard.press('ControlOrMeta+I'); await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-input')).toBeFocused(); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIInitialState); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState), }, { name: 'Ask - AI Mode: Assistant - Button', @@ -202,9 +239,9 @@ const searchTestCases: Test[] = [ await page.getByTestId('ai-chat-button').click(); await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-input')).toBeFocused(); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIInitialState); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState), }, { name: 'Ask - AI Mode: Assistant - URL query (Initial)', @@ -219,9 +256,9 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('search-input')).toBeEmpty(); await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-input')).toBeFocused(); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIInitialState); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState), }, { name: 'Ask - AI Mode: Assistant - URL query (Results)', @@ -237,12 +274,13 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT); await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible(); - await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, { - timeout: 60_000, - }); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIResponse); + // Wait for the full response (incl. follow-up suggestions) to settle before + // asserting/screenshotting, rather than racing a fixed suggestion count. + await waitForAIChatResponse(page); + await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible(); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse), }, ]; @@ -1323,6 +1361,7 @@ const testCases: TestsCase[] = [ pageActions: { markdown: false, externalAI: false, + items: [], }, }), run: waitForCookiesDialog, @@ -2221,9 +2260,13 @@ const testCases: TestsCase[] = [ await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab await expect(iframe.getByTestId('ai-chat')).toBeVisible(); - - await iframe.owner().evaluate(overrideAIInitialState); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIInitialState), }, { name: 'API - navigateToPage', @@ -2257,8 +2300,15 @@ const testCases: TestsCase[] = [ await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText( AI_PROMPT ); - await iframe.owner().evaluate(overrideAIResponse); + // Wait for the full response to settle before normalizing. + await waitForAIChatResponse(iframe); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIResponse), }, { name: 'Configuration - Suggested questions', @@ -2284,8 +2334,13 @@ const testCases: TestsCase[] = [ await expect( iframe.getByTestId('ai-chat-suggested-question').nth(2) ).toHaveText('What can you do?'); - await iframe.owner().evaluate(overrideAIInitialState); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIInitialState), }, { name: 'Configuration - Custom action buttons', @@ -2367,8 +2422,15 @@ const testCases: TestsCase[] = [ await actions.nth(3).click(); await expect(page.locator('#gitbook-widget-window')).not.toBeVisible(); await page.locator('#gitbook-widget-button').click(); - await iframe.owner().evaluate(overrideAIResponse); + // Wait for the response posted above to settle before normalizing. + await waitForAIChatResponse(iframe); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIResponse), }, { name: 'Configuration - Custom tools', @@ -2412,8 +2474,16 @@ const testCases: TestsCase[] = [ await expect(toolConfirmation).toBeVisible({ timeout: 30000, }); - await iframe.owner().evaluate(overrideAIResponse); + // The turn settles (aria-busy clears) once the stream pauses on the + // confirmation control; wait for that before normalizing. + await waitForAIChatResponse(iframe); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIResponse), }, ], }, diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index fe206d040..dca42428b 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -13,6 +13,7 @@ import { CustomizationIconsStyle, CustomizationLinksStyle, CustomizationLocale, + CustomizationPageActionType, CustomizationSearchStyle, CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, @@ -21,7 +22,14 @@ import { type SiteCustomizationSettings, SiteExternalLinksTarget, } from '@gitbook/api'; -import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test'; +import { + type BrowserContext, + type FrameLocator, + type Page, + type Response, + expect, + test, +} from '@playwright/test'; import deepMerge from 'deepmerge'; import rison from 'rison'; import type { DeepPartial } from 'ts-essentials'; @@ -39,6 +47,16 @@ export interface Test { * Test to run */ run?: (page: Page, response: Response | null) => Promise handleSubmit(value as string)}
diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
index dc1d72d15..1623dcbb2 100644
--- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
@@ -151,10 +151,7 @@ function InlineLinkTooltipWrapper(props: {
;
target: {
- href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
- openInNewTabLabel: string;
children: React.ReactNode;
}) {
- const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
+ const { isExternal, target, breadcrumbs, children } = props;
return (
{children}
-
-
- {breadcrumbs && breadcrumbs.length > 0 ? (
-
- {breadcrumbs.map((crumb, index) => {
- const Tag = crumb.href ? StyledLink : 'div';
+
+ {breadcrumbs && breadcrumbs.length > 0 ? (
+
+ {breadcrumbs.map((crumb, index) => {
+ const Tag = crumb.href ? StyledLink : 'div';
- return (
-
- {index !== 0 ? (
-
+ return (
+
+ {index !== 0 ? (
+
+ ) : null}
+
+ {crumb.icon ? (
+
+ {crumb.icon}
+
) : null}
-
- {crumb.icon ? (
-
- {crumb.icon}
-
- ) : null}
- {crumb.label}
-
-
- );
- })}
-
- ) : null}
-
- {target.icon ? (
-
- {target.icon}
-
- ) : null}
- {target.text}
+ {crumb.label}
+
+
+ );
+ })}
-
- {!isSamePage && target.href ? (
-
) : null}
+
+ {target.icon ? (
+ {target.icon}
+ ) : null}
+ {target.text}
+
{target.subText ? {target.subText}
: null}
diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx
index 6286bbce7..8904145fd 100644
--- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx
+++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx
@@ -1,4 +1,4 @@
-import { GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
+import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
@@ -82,7 +82,16 @@ export async function IntegrationBlock(props: BlockProps
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
+ headless: block.type === 'openapi-operation' ? block.data.headless : undefined,
})}
className="openapi-block"
/>
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx
index 2c6b21946..00b6b25cc 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx
@@ -56,6 +56,7 @@ async function OpenAPIWebhookBody(props: BlockProps) {
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
+ headless: block.data.headless,
})}
className="openapi-block"
/>
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
index 9f37bbd2f..0a102bf13 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx
@@ -26,8 +26,9 @@ export function getOpenAPIContext(args: {
context: GitBookAnyContext | undefined;
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
+ headless?: boolean;
}): OpenAPIContextInput {
- const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
+ const { props, specUrl, context, expandAllResponses, expandAllModelSections, headless } = args;
const { block } = props;
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
@@ -99,6 +100,7 @@ export function getOpenAPIContext(args: {
),
expandAllResponses: expandAllResponses || props.context.mode === 'print',
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
+ headless,
id: block.meta?.id,
blockKey: block.key,
locale,
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css
index fa5ae7752..faec737a1 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css
@@ -272,7 +272,7 @@ body {
}
.scalar-activate-button {
@apply flex gap-2 items-center;
- @apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
+ @apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-lg straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all;
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
index 25abf7ab8..b4f52a72a 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
@@ -40,7 +40,7 @@
.openapi-deprecated,
.openapi-stability {
- @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-stability-alpha {
@@ -78,7 +78,7 @@
}
.openapi-markdown code {
- @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-markdown pre code {
@@ -101,7 +101,17 @@
/* Method Tags */
.openapi-method,
.openapi-statuscode {
- @apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
+ @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 mr-2 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,
@@ -130,7 +140,8 @@
.openapi-method-head,
.openapi-method-options,
-.openapi-method-trace {
+.openapi-method-trace,
+.openapi-method-hook {
@apply bg-tint;
}
@@ -149,7 +160,7 @@
}
.openapi-column-preview {
- @apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
+ @apply flex flex-col flex-1 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] scroll-mt-4 top-[calc(var(--toc-top-offset)+1.5rem)];
}
.openapi-column-preview-body {
@@ -276,11 +287,11 @@
}
.openapi-schema-enum-value:first-child {
- @apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
+ @apply rounded-l-md straight-corners:rounded-none circular-corners:rounded-l-lg ml-0;
}
.openapi-schema-enum-value:last-child {
- @apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
+ @apply rounded-r-md straight-corners:rounded-none circular-corners:rounded-r-lg;
}
/* Schema Description */
@@ -314,7 +325,7 @@
.openapi-schema-pattern code,
.openapi-schema-enum-value code,
.openapi-schema-default code {
- @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
/* Authentication */
@@ -344,7 +355,7 @@
}
.openapi-securities-url {
- @apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
+ @apply ml-0.5 px-0.5 rounded-md straight-corners:rounded-none circular-corners:rounded-lg hover:bg-tint dark:hover:bg-tint-hover transition-colors;
}
.openapi-securities-body {
@@ -455,16 +466,12 @@
@apply max-w-full overflow-hidden shrink pl-0.5 py-0.5;
}
-.openapi-response-examples-header .openapi-select > button .openapi-statuscode {
- @apply h-full;
-}
-
.openapi-codesample-header-content {
@apply flex flex-row items-center justify-between h-fit p-2.5;
}
.openapi-codesample-header-content .openapi-path {
- @apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
+ @apply flex items-center font-mono text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path-title {
@@ -491,17 +498,13 @@
}
.openapi-path-variable {
- @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
+ @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-none before:content-none! after:!content-none;
}
.openapi-path-server {
@apply text-tint inline;
}
-.openapi-summary .openapi-path .openapi-method {
- @apply m-0 items-center flex px-2 py-1 h-6;
-}
-
.openapi-path-title {
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
@apply whitespace-nowrap md:whitespace-normal;
@@ -537,7 +540,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
- @apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
+ @apply border shrink min-h-32 overflow-hidden rounded-corners:rounded-xl circular-corners:rounded-2xl straight-corners:rounded-xs bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
}
.openapi-response-examples-panel {
@@ -616,7 +619,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
- @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
+ @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg leading-none;
@apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
}
@@ -649,7 +652,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-popover {
- @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
+ @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -662,7 +665,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
- @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
+ @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded-md straight-corners:rounded-none circular-corners:rounded-lg !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
@@ -763,7 +766,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab {
- @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
+ @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded-md straight-corners:rounded-none circular-corners:rounded-lg;
}
.openapi-tabs-tab[aria-selected="true"] {
@@ -777,7 +780,7 @@ body:has(.openapi-select-popover) {
/* Disclosure group */
.openapi-disclosure-group {
- @apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none circular-corners:last:rounded-b-xl first:rounded-t-md straight-corners:first:rounded-none circular-corners:first:rounded-t-xl first:border-t relative;
+ @apply border-tint-subtle transition-all border-b border-x overflow-auto rounded-corners:last:rounded-b-xl circular-corners:last:rounded-b-2xl straight-corners:last:rounded-b-xs rounded-corners:first:rounded-t-xl circular-corners:first:rounded-t-2xl straight-corners:first:rounded-t-xs first:border-t relative;
}
.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) {
@@ -830,7 +833,7 @@ body:has(.openapi-select-popover) {
/* Disclosure */
.openapi-schemas-disclosure {
- @apply border-t border-x last:border-b border-tint-subtle !ring-0 first:!rounded-t-xl last:!rounded-b-xl !rounded-none;
+ @apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
@@ -875,7 +878,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) {
- @apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
+ @apply rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
}
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -898,7 +901,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-trigger-label {
- @apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
+ @apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded-md straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
}
.openapi-disclosure-trigger-label span {
@@ -961,7 +964,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tooltip {
- @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg font-medium px-1.5 py-0.5 shadow-sm text-[13px];
+ @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px];
}
.openapi-tooltip svg {
@@ -1025,7 +1028,7 @@ body:has(.openapi-select-popover) {
}
.openapi-path-copy-button {
- @apply p-1 flex rounded-md straight-corners:rounded-none;
+ @apply p-1 flex rounded-lg straight-corners:rounded-none;
@apply hover:bg-tint dark:hover:bg-tint-hover;
}
@@ -1042,7 +1045,7 @@ body:has(.openapi-select-popover) {
}
.openapi-required-scopes {
- @apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
+ @apply border text-base rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium mx-0;
}
.openapi-required-scopes .openapi-required-scopes-header {
diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx
index 73a708ad2..605d82f6a 100644
--- a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx
@@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
+import { TableSearchRecord } from './TableSearch';
import { getColumnWidth } from './layout';
import { getColumnVerticalAlignment } from './utils';
@@ -14,19 +15,21 @@ export function RecordRow(
fixedColumns: string[];
}
) {
- const { view, autoSizedColumns, fixedColumns, block, context } = props;
+ const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
return (
-
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
@@ -63,6 +66,6 @@ export function RecordRow(
);
})}
-
+
);
}
diff --git a/packages/gitbook/src/components/DocumentView/Table/Table.tsx b/packages/gitbook/src/components/DocumentView/Table/Table.tsx
index af0236f1d..cb280002b 100644
--- a/packages/gitbook/src/components/DocumentView/Table/Table.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/Table.tsx
@@ -1,4 +1,4 @@
-import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
+import type { DocumentBlockTable } from '@gitbook/api';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
@@ -6,11 +6,23 @@ import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { StickyViewGrid } from './StickyViewGrid';
+import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
import { ViewCards } from './ViewCards';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { getViewGridLayout, hasVisibleHeader } from './layout';
+import {
+ type TableRecordKV,
+ getTableCheckboxColumns,
+ getTableRecordSearchData,
+ getTableSelectColumns,
+} from './search';
-export type TableRecordKV = [string, DocumentTableRecord];
+export type { TableRecordKV };
+
+/**
+ * Only show the table search once there are enough records that searching is useful.
+ */
+const MIN_RECORDS_FOR_SEARCH = 7;
export interface TableViewProps extends BlockProps {
view: View;
@@ -22,9 +34,40 @@ export function Table(props: BlockProps) {
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
- const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
- return a[1].orderIndex.localeCompare(b[1].orderIndex);
- });
+ const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) =>
+ a[1].orderIndex.localeCompare(b[1].orderIndex)
+ );
+
+ const showSearch = context.mode !== 'print' && records.length >= MIN_RECORDS_FOR_SEARCH;
+ const searchRecords = showSearch
+ ? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
+ : [];
+
+ return (
+
+
+ {showSearch ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+/**
+ * Renders the table itself (grid or cards view) for the given records.
+ */
+function TableView({
+ isOffscreen,
+ records,
+ ...props
+}: BlockProps & { isOffscreen: boolean; records: TableRecordKV[] }) {
+ const { block, context, style } = props;
switch (block.data.view.type) {
case 'cards':
diff --git a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx
new file mode 100644
index 000000000..4d01fe1fe
--- /dev/null
+++ b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx
@@ -0,0 +1,310 @@
+'use client';
+
+import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
+import { tString, useLanguage } from '@/intl/client';
+import { type ClassValue, tcls } from '@/lib/tailwind';
+import { Icon } from '@gitbook/icons';
+import React from 'react';
+import type { TableCheckboxColumn, TableSelectColumn } from './search';
+import { type SelectedOptions, recordMatches } from './searchMatch';
+
+/**
+ * Client-side table search.
+ *
+ * Site pages are statically rendered and can't read `searchParams`, so filtering happens entirely
+ * on the client rather than round-tripping through the server. The provider matches every record
+ * once and exposes the set of visible ids; each row/card just looks itself up by id.
+ */
+
+/** Per-record matching data, computed on the server. */
+export interface TableSearchRecordData {
+ /** Record key, matching the `key` passed to ``. */
+ id: string;
+ searchText: string;
+ selectValues?: Record;
+ checkboxValues?: Record;
+}
+
+type TableSearchContextValue = {
+ query: string;
+ setQuery: (query: string) => void;
+ selectedOptions: SelectedOptions;
+ toggleOption: (column: string, value: string) => void;
+ /** Checkbox columns whose filter is currently enabled. */
+ checkedColumns: ReadonlySet;
+ toggleCheckbox: (column: string) => void;
+ /**
+ * Ids of the records matching the active filters, or `null` when no filter is active
+ * (in which case every record is shown).
+ */
+ visibleIds: ReadonlySet | null;
+ /** True when there are records but the active filters match none of them. */
+ isEmpty: boolean;
+};
+
+const TableSearchContext = React.createContext(null);
+
+/**
+ * Holds the search query and active filters for a single table.
+ */
+export function TableSearchProvider(props: {
+ records?: TableSearchRecordData[];
+ children: React.ReactNode;
+}) {
+ const { records = [] } = props;
+ const [query, setQuery] = React.useState('');
+ const [selectedOptions, setSelectedOptions] = React.useState(() => ({}));
+ const [checkedColumns, setCheckedColumns] = React.useState>(
+ () => new Set()
+ );
+
+ const toggleOption = React.useCallback((column: string, value: string) => {
+ setSelectedOptions((previous) => {
+ const values = new Set(previous[column]);
+ if (values.has(value)) {
+ values.delete(value);
+ } else {
+ values.add(value);
+ }
+
+ const next = { ...previous };
+ if (values.size === 0) {
+ delete next[column];
+ } else {
+ next[column] = values;
+ }
+ return next;
+ });
+ }, []);
+
+ const toggleCheckbox = React.useCallback((column: string) => {
+ setCheckedColumns((previous) => {
+ const next = new Set(previous);
+ if (next.has(column)) {
+ next.delete(column);
+ } else {
+ next.add(column);
+ }
+ return next;
+ });
+ }, []);
+
+ const hasActiveFilters =
+ query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
+
+ // Match every record once, here, rather than in each row — rows just look themselves up by id.
+ const visibleIds = React.useMemo(() => {
+ if (!hasActiveFilters) {
+ return null;
+ }
+
+ const ids = new Set();
+ for (const record of records) {
+ if (
+ recordMatches(
+ record.searchText,
+ record.selectValues,
+ record.checkboxValues,
+ query,
+ selectedOptions,
+ checkedColumns
+ )
+ ) {
+ ids.add(record.id);
+ }
+ }
+ return ids;
+ }, [records, query, selectedOptions, checkedColumns, hasActiveFilters]);
+
+ const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0;
+
+ const value = React.useMemo(
+ () => ({
+ query,
+ setQuery,
+ selectedOptions,
+ toggleOption,
+ checkedColumns,
+ toggleCheckbox,
+ visibleIds,
+ isEmpty,
+ }),
+ [query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
+ );
+
+ return (
+ {props.children}
+ );
+}
+
+function useTableSearch(): TableSearchContextValue {
+ const context = React.useContext(TableSearchContext);
+ if (!context) {
+ throw new Error('useTableSearch must be used within a ');
+ }
+ return context;
+}
+
+/**
+ * The search form rendered above a table, with a multi-select filter dropdown per select column.
+ */
+export function TableSearchInput(props: {
+ selectColumns?: TableSelectColumn[];
+ checkboxColumns?: TableCheckboxColumn[];
+ className?: ClassValue;
+}) {
+ const { selectColumns = [], checkboxColumns = [] } = props;
+ const language = useLanguage();
+ const { query, setQuery } = useTableSearch();
+ const hasFilters = selectColumns.length > 0 || checkboxColumns.length > 0;
+
+ return (
+ event.stopPropagation()}
+ >
+ {selectColumns.map((column) => (
+
+ ))}
+ {checkboxColumns.map((column) => (
+
+ ))}
+
+ ) : undefined
+ }
+ />
+ );
+}
+
+/**
+ * Shown below the table when the active filters match no records.
+ */
+export function TableSearchEmpty(props: { className?: ClassValue }) {
+ const language = useLanguage();
+ const { query, isEmpty } = useTableSearch();
+
+ if (!isEmpty) {
+ return null;
+ }
+
+ const trimmed = query.trim();
+ return (
+
+ {trimmed
+ ? tString(language, 'search_no_results_for', trimmed)
+ : tString(language, 'search_no_results')}
+
+ );
+}
+
+/**
+ * A blank multi-select dropdown button for a single select column. Becomes `active` while any
+ * of its options are selected.
+ */
+function SelectFilterDropdown(props: { column: TableSelectColumn }) {
+ const { column } = props;
+ const language = useLanguage();
+ const { selectedOptions, toggleOption } = useTableSearch();
+ const selectedValues = selectedOptions[column.id];
+ const activeCount = selectedValues?.size ?? 0;
+ // Fall back to a generic "Filter" label when the column title is hidden/empty.
+ const label = column.label.trim() || tString(language, 'search_scope_title');
+
+ return (
+ 0}
+ label={activeCount > 1 ? `${label} · ${activeCount}` : label}
+ trailing={ }
+ />
+ }
+ >
+ {column.options.map((option) => {
+ const selected = selectedValues?.has(option.value) ?? false;
+ return (
+ {
+ // Keep the menu open so several options can be toggled at once.
+ event.preventDefault();
+ toggleOption(column.id, option.value);
+ }}
+ >
+ {option.label || option.value}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * A checkbox control filtering the table to records where the given checkbox column is checked.
+ */
+function CheckboxFilter(props: { column: TableCheckboxColumn }) {
+ const { column } = props;
+ const language = useLanguage();
+ const { checkedColumns, toggleCheckbox } = useTableSearch();
+ const checked = checkedColumns.has(column.id);
+ const id = `table-search-checkbox-${column.id}`;
+ // Fall back to a generic "Filter" label when the column title is hidden/empty.
+ const label = column.label.trim() || tString(language, 'search_scope_title');
+
+ return (
+
+ );
+}
+
+type TableSearchRecordProps = Omit, 'className'> & {
+ /** Record id, matching the `id` of the data passed to ``. */
+ recordId: string;
+ /** Display utility applied when the record matches (e.g. `flex` for rows, `contents` for cards). */
+ visibleClassName: string;
+ className?: ClassValue;
+ children: React.ReactNode;
+};
+
+/**
+ * Wraps a single table record (a grid row or a card) and hides it when it doesn't match
+ * the current filters. Matching happens once in the provider; here we just look up the id.
+ */
+export function TableSearchRecord(props: TableSearchRecordProps) {
+ const { recordId, visibleClassName, className, children, ...rest } = props;
+ const { visibleIds } = useTableSearch();
+ const matches = visibleIds === null || visibleIds.has(recordId);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx
index 761078f98..f86eecd3e 100644
--- a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx
+++ b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx
@@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordCard } from './RecordCard';
import type { TableViewProps } from './Table';
+import { TableSearchRecord } from './TableSearch';
export function ViewCards(props: TableViewProps) {
const { block, view, records, style } = props;
@@ -21,7 +22,15 @@ export function ViewCards(props: TableViewProps) {
)}
>
{records.map((record) => {
- return ;
+ return (
+
+
+
+ );
})}