mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 18:43:29 +00:00
Merge branch 'main' into peter/rnd-12709-support-permanent-308-site-redirects
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix inline Ask AI buttons opening a configured custom assistant.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix breadcrumbs for cross-space page links in grouped sites.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Move paragraph block styles behind a single `paragraph` class and drop the `page-cover-background:` gate from the cover-contrast text. The gate combined with the per-paragraph `:not(:has(...))` made every DOM insertion re-style all paragraphs, which froze very long pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Render horizontal and vertical merged table cells on published pages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Scroll to the top when selecting a search result for the page already being viewed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add end-to-end coverage for root and nested external links in site section navigation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected.
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.198.0",
|
||||
"@gitbook/api": "0.199.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -726,7 +726,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.198.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZGXqYip6YFsCM5rqlJ+n8/Esw1bMNK4whqiMoWMeQgMKEX5YXVQaBuVRxU1jDEI49kup+T56xPq3I9g6Wcayng=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.199.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yLxkSTXlGk7jbtThV2vpnTfqZTq6yLgMJt6jhDGqOmP0LsYAVYYtg8NPf2tXp37aF46m3Z0D908wfSQyu374cg=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@gitbook/api": "0.198.0",
|
||||
"@gitbook/api": "0.199.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -707,6 +707,150 @@ const testCases: TestsCase[] = [
|
||||
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Root external link renders in the configured position',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const rootSections = page.locator('[data-gb-sections]');
|
||||
const rootItems = rootSections.locator(':scope > li');
|
||||
|
||||
await expect(rootItems).toHaveCount(4);
|
||||
await expect(rootItems.nth(0)).toContainText('Home');
|
||||
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
|
||||
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
|
||||
await expect(rootItems.last()).toContainText('Gitbook Docs');
|
||||
await expect(
|
||||
rootSections.getByRole('link', { name: 'Gitbook Docs' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Root external link has the configured contract',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
const rootLink = page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('link', { name: 'Gitbook Docs' });
|
||||
|
||||
await expect(rootLink).toBeVisible();
|
||||
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
|
||||
await expect(rootLink).not.toHaveAttribute('target');
|
||||
await expect(rootLink).not.toHaveAttribute('rel');
|
||||
await expect(rootLink).toHaveAttribute('data-active', 'false');
|
||||
await expect(rootLink).not.toHaveAttribute('aria-current');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested external link renders in the configured position',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
|
||||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||||
await expect(nestedLink).toBeVisible();
|
||||
|
||||
const nestedItems = nestedLink
|
||||
.locator('xpath=ancestor::ul[1]')
|
||||
.locator(':scope > li');
|
||||
await expect(nestedItems).toHaveCount(3);
|
||||
await expect(nestedItems.nth(0)).toContainText('Section C');
|
||||
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
|
||||
await expect(nestedItems.last()).toContainText('Gitbook Site');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Nested external link has the configured contract',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
|
||||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||||
await expect(nestedLink).toBeVisible();
|
||||
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
|
||||
await expect(nestedLink).not.toHaveAttribute('target');
|
||||
await expect(nestedLink).not.toHaveAttribute('rel');
|
||||
await expect(nestedLink).not.toHaveAttribute('aria-current');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'External links use the configured window open behavior',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForHydration(page);
|
||||
|
||||
const windowOpenCalls: {
|
||||
url: string;
|
||||
target: string;
|
||||
features: string | undefined;
|
||||
}[] = [];
|
||||
await page.exposeFunction(
|
||||
'recordExternalWindowOpen',
|
||||
(url: string, target: string, features?: string) => {
|
||||
windowOpenCalls.push({ url, target, features });
|
||||
}
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const recordExternalWindowOpen = (
|
||||
window as unknown as {
|
||||
recordExternalWindowOpen: (
|
||||
url: string,
|
||||
target: string,
|
||||
features?: string
|
||||
) => void;
|
||||
}
|
||||
).recordExternalWindowOpen;
|
||||
window.open = ((url, target, features) => {
|
||||
void recordExternalWindowOpen(
|
||||
url?.toString() ?? '',
|
||||
target ?? '',
|
||||
features
|
||||
);
|
||||
return null;
|
||||
}) as typeof window.open;
|
||||
});
|
||||
|
||||
const initialURL = page.url();
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('link', { name: 'Gitbook Docs' })
|
||||
.click();
|
||||
await expect.poll(() => windowOpenCalls.length).toBe(1);
|
||||
expect(windowOpenCalls[0]).toEqual({
|
||||
url: 'https://gitbook.com/docs',
|
||||
target: '_self',
|
||||
features: undefined,
|
||||
});
|
||||
await expect(page).toHaveURL(initialURL);
|
||||
|
||||
await page
|
||||
.locator('[data-gb-sections]')
|
||||
.getByRole('button', { name: 'Test Section Group 2' })
|
||||
.hover();
|
||||
await page.getByRole('link', { name: 'Gitbook Site' }).click();
|
||||
await expect.poll(() => windowOpenCalls.length).toBe(2);
|
||||
expect(windowOpenCalls[1]).toEqual({
|
||||
url: 'https://gitbook.com',
|
||||
target: '_self',
|
||||
features: undefined,
|
||||
});
|
||||
await expect(page).toHaveURL(initialURL);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -236,3 +236,99 @@ test.describe('select syncing across groups (click-driven)', () => {
|
||||
await expectGroupShows(page, 'b', other, 'go');
|
||||
});
|
||||
});
|
||||
|
||||
interface NestedSpec {
|
||||
outer: string[];
|
||||
inner: string[];
|
||||
/** Which of the outer options hosts the nested group. */
|
||||
host: string;
|
||||
/**
|
||||
* Emit the nested group's stylesheet before the outer one, as happens when a group with the
|
||||
* same option set appears earlier on the page and its deduped sheet lands in `<head>` first.
|
||||
*/
|
||||
innerStyleFirst?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs`
|
||||
* produces: panes are direct children of the element carrying the set class, and a pane's body is
|
||||
* wrapped in a padding div before the nested group.
|
||||
*/
|
||||
async function renderNestedGroups(page: Page, spec: NestedSpec) {
|
||||
const { outer, inner, host, innerStyleFirst = false } = spec;
|
||||
const outerScope = selectSetClassName(outer);
|
||||
const innerScope = selectSetClassName(inner);
|
||||
|
||||
const innerPanes = inner
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="inner-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
const innerGroup = `<div class="${innerScope}" data-select-group>${innerPanes}</div>`;
|
||||
|
||||
const outerPanes = outer
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="outer-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}><div>${slug}${slug === host ? innerGroup : ''}</div></div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const styles = [generateSelectCSS(outer), generateSelectCSS(inner)];
|
||||
if (innerStyleFirst) {
|
||||
styles.reverse();
|
||||
}
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head>${styles.map((css) => `<style>${css}</style>`).join('')}</head><body><div class="${outerScope}" data-select-group>${outerPanes}</div></body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also
|
||||
* a descendant of the outer group, a sheet that reached descendants instead of children would hide
|
||||
* the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body.
|
||||
*/
|
||||
test.describe('select CSS visibility in nested groups', () => {
|
||||
const outer = ['macos', 'windows'];
|
||||
const inner = ['npm', 'yarn'];
|
||||
|
||||
test('shows both defaults when nothing is selected', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('keeps the nested group resolved when an outer option is activated', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await applySelection(page, ['macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves a nested group hosted by a non-default outer option', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'windows' });
|
||||
await applySelection(page, ['windows']);
|
||||
await expect(page.getByTestId('outer-pane-windows')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves each group against its own options', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos' });
|
||||
await applySelection(page, ['yarn', 'macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
|
||||
});
|
||||
|
||||
test('resolves the same way whichever stylesheet comes first', async ({ page }) => {
|
||||
await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true });
|
||||
await applySelection(page, ['yarn', 'macos']);
|
||||
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
|
||||
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-1
@@ -53,8 +53,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
|
||||
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
|
||||
// Never shown on touch / hover-less contexts.
|
||||
'not-pointer-fine:hidden',
|
||||
// Hidden where an overflow-clipped ancestor would cut it off (tables, record cards).
|
||||
// Hidden where an overflow-clipped ancestor would cut it off (ARIA and native
|
||||
// tables, record cards).
|
||||
'in-[[role=table]]:hidden',
|
||||
'in-[table]:hidden',
|
||||
'in-[[data-card]]:hidden',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useAI, useAIChatController, useAIChatState } from '../AI';
|
||||
import { useAI, useAIChatState } from '../AI';
|
||||
import { Button, type ButtonProps, Input } from '../primitives';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
@@ -18,17 +18,13 @@ export function InlineActionButton(
|
||||
const { action, query, buttonProps } = props;
|
||||
|
||||
const { assistants } = useAI();
|
||||
const chatController = useAIChatController();
|
||||
const chatState = useAIChatState();
|
||||
const setSearchState = useSetSearchState();
|
||||
const language = useLanguage();
|
||||
|
||||
const handleSubmit = (value: string) => {
|
||||
if (action === 'ask') {
|
||||
chatController.open();
|
||||
if (value ?? query) {
|
||||
chatController.postMessage({ message: value ?? query });
|
||||
}
|
||||
assistants[0]?.open(value || query);
|
||||
} else if (action === 'search') {
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -12,22 +12,13 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
|
||||
const { block, style, ...contextProps } = props;
|
||||
const { context } = contextProps;
|
||||
|
||||
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
|
||||
const inlineButtonStyle =
|
||||
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
|
||||
|
||||
const paragraph = (
|
||||
<p
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
data-cover-aware-text={context.isPageBody ? '' : undefined}
|
||||
className={tcls(
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody &&
|
||||
'page-cover-background:[&:not(:has(.button,input))]:text-contrast-cover',
|
||||
inlineButtonStyle,
|
||||
style,
|
||||
getTextAlignment(block.data?.align)
|
||||
)}
|
||||
// Paragraph styles live in globals.css (`.paragraph`) to keep the class attribute short.
|
||||
className={tcls('paragraph', style, getTextAlignment(block.data?.align))}
|
||||
>
|
||||
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { getTableCellMerge } from './cellMerges';
|
||||
import { getColumnWidth, getViewGridLayout, hasVisibleHeader } from './layout';
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import { StickyViewGrid } from './StickyViewGrid';
|
||||
import type { TableGridViewProps } from './Table';
|
||||
import { TableHoverTable } from './TableHoverTable';
|
||||
import { TableSearchTableBody } from './TableSearch';
|
||||
import { type VerticalAlignment, getColumnAlignment, getColumnVerticalAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/** Semantic table renderer used when a cell spans multiple records. */
|
||||
export function NativeViewGrid(props: TableGridViewProps) {
|
||||
const { block, view, context, style } = props;
|
||||
const { tableWidth } = getViewGridLayout({
|
||||
block,
|
||||
view,
|
||||
mode: context.mode,
|
||||
});
|
||||
const tableContainerClassName = tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
|
||||
const withHeader = hasVisibleHeader(block, view);
|
||||
const withStickyHeader = withHeader && context.mode !== 'print' && view.stickyHeader === true;
|
||||
const withStickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
|
||||
const header = withHeader ? (
|
||||
<NativeViewGridHeader
|
||||
{...props}
|
||||
stickyHeader={withStickyHeader}
|
||||
stickyFirstColumn={withStickyFirstColumn}
|
||||
className={tcls(
|
||||
withStickyHeader
|
||||
? [
|
||||
'mb-0 border-t border-r border-l',
|
||||
'group-data-[scrollable=false]/table:mb-1',
|
||||
'group-data-[scrollable=false]/table:rounded-b-lg',
|
||||
'group-data-[scrollable=true]/table:border-t-0',
|
||||
'group-data-[scrollable=true]/table:border-x-0',
|
||||
]
|
||||
: undefined
|
||||
)}
|
||||
/>
|
||||
) : undefined;
|
||||
const body = (
|
||||
<NativeViewGridBody
|
||||
{...props}
|
||||
withHeader={withHeader}
|
||||
stickyFirstColumn={withStickyFirstColumn}
|
||||
/>
|
||||
);
|
||||
|
||||
if (withStickyHeader || withStickyFirstColumn) {
|
||||
return (
|
||||
<StickyViewGrid
|
||||
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
|
||||
stickyHeader={withStickyHeader}
|
||||
tableClassName={tableContainerClassName}
|
||||
withTableRole={false}
|
||||
header={header}
|
||||
>
|
||||
{body}
|
||||
</StickyViewGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
|
||||
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
|
||||
<div className={tcls('flex flex-col', tableContainerClassName)}>
|
||||
{header}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeViewGridHeader(
|
||||
props: TableGridViewProps & {
|
||||
stickyHeader: boolean;
|
||||
stickyFirstColumn: boolean;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const { block, view, stickyHeader, stickyFirstColumn, className } = props;
|
||||
const firstVisibleColumn = view.columns[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={tcls(
|
||||
'mb-1 rounded-lg border border-tint-subtle bg-tint',
|
||||
stickyHeader || !stickyFirstColumn ? 'overflow-hidden' : undefined,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<table className="w-full table-fixed border-separate border-spacing-0">
|
||||
<NativeViewGridColumns {...props} />
|
||||
<thead>
|
||||
<tr>
|
||||
{view.columns.map((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isStickyFirstColumn =
|
||||
stickyFirstColumn && column === firstVisibleColumn;
|
||||
return (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
className={tcls(
|
||||
'relative px-3 py-2 font-medium text-sm text-tint-strong',
|
||||
isStickyFirstColumn
|
||||
? stickyHeader
|
||||
? 'z-20 bg-tint'
|
||||
: 'sticky left-0 z-20 bg-tint'
|
||||
: undefined,
|
||||
getColumnAlignment(definition)
|
||||
)}
|
||||
style={{
|
||||
left:
|
||||
stickyHeader && isStickyFirstColumn
|
||||
? 'calc(-1 * var(--table-sticky-scroll-left, 0px))'
|
||||
: undefined,
|
||||
}}
|
||||
title={definition.title}
|
||||
>
|
||||
{definition.title}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeViewGridBody(
|
||||
props: TableGridViewProps & {
|
||||
withHeader: boolean;
|
||||
stickyFirstColumn: boolean;
|
||||
}
|
||||
) {
|
||||
const { block, view, records, withHeader, stickyFirstColumn, cellMergeLayout } = props;
|
||||
const firstVisibleColumn = view.columns[0];
|
||||
const lastVisibleColumn = view.columns.at(-1);
|
||||
const recordsById = new Map(records.map((record) => [record[0], record] as const));
|
||||
const recordIndexes = new Map(records.map((record, index) => [record[0], index] as const));
|
||||
|
||||
return (
|
||||
<TableHoverTable
|
||||
className={tcls(
|
||||
'w-full table-fixed border-separate border-spacing-0',
|
||||
'[&>tbody>tr>td[data-table-hovered]]:bg-tint-hover',
|
||||
'[&>tbody>tr+tr>td]:border-t',
|
||||
'[&>tbody+tbody>tr:first-child>td]:border-t'
|
||||
)}
|
||||
>
|
||||
<NativeViewGridColumns {...props} />
|
||||
{withHeader ? (
|
||||
<thead className="sr-only">
|
||||
<tr>
|
||||
{view.columns.map((column) => {
|
||||
const definition = block.data.definition[column];
|
||||
return definition ? (
|
||||
<th key={column} scope="col">
|
||||
{definition.title}
|
||||
</th>
|
||||
) : null;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
) : null}
|
||||
{cellMergeLayout.recordGroups.map((recordGroup) => (
|
||||
<TableSearchTableBody key={recordGroup[0]} recordIds={recordGroup}>
|
||||
{recordGroup.map((recordId) => {
|
||||
const record = recordsById.get(recordId);
|
||||
const recordIndex = recordIndexes.get(recordId);
|
||||
if (!record || recordIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={recordId}>
|
||||
{view.columns.map((column) => {
|
||||
const cellMerge = getTableCellMerge(
|
||||
cellMergeLayout,
|
||||
recordId,
|
||||
column
|
||||
);
|
||||
if (cellMerge && !cellMerge.isAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = block.data.definition[column];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const verticalAlignment =
|
||||
getColumnVerticalAlignment(definition);
|
||||
const renderedColumns = cellMerge?.merge.columns ?? [column];
|
||||
const isLastColumn =
|
||||
renderedColumns.at(-1) === lastVisibleColumn;
|
||||
const isStickyFirstColumn =
|
||||
stickyFirstColumn && column === firstVisibleColumn;
|
||||
const rowSpan =
|
||||
cellMerge && cellMerge.merge.rowSpan > 1
|
||||
? cellMerge.merge.rowSpan
|
||||
: undefined;
|
||||
const colSpan =
|
||||
cellMerge && cellMerge.merge.colSpan > 1
|
||||
? cellMerge.merge.colSpan
|
||||
: undefined;
|
||||
return (
|
||||
<td
|
||||
key={column}
|
||||
rowSpan={rowSpan}
|
||||
colSpan={colSpan}
|
||||
aria-rowspan={rowSpan}
|
||||
aria-colspan={colSpan}
|
||||
data-table-row-start={recordIndex}
|
||||
data-table-row-end={
|
||||
recordIndex + (cellMerge?.merge.rowSpan ?? 1) - 1
|
||||
}
|
||||
className={tcls(
|
||||
'relative px-3 py-2 text-sm transition-colors',
|
||||
!isLastColumn
|
||||
? 'border-tint-subtle border-r'
|
||||
: undefined,
|
||||
getNativeCellVerticalAlignment(verticalAlignment),
|
||||
isStickyFirstColumn
|
||||
? 'sticky left-0 z-10 bg-tint-base'
|
||||
: undefined
|
||||
)}
|
||||
>
|
||||
<RecordColumnValue
|
||||
{...props}
|
||||
record={record}
|
||||
column={column}
|
||||
verticalAlignment={verticalAlignment}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableSearchTableBody>
|
||||
))}
|
||||
</TableHoverTable>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeViewGridColumns(props: TableGridViewProps) {
|
||||
const { block, view, context } = props;
|
||||
const { columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
|
||||
block,
|
||||
view,
|
||||
mode: context.mode,
|
||||
});
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{view.columns.map((column) => (
|
||||
<col
|
||||
key={column}
|
||||
style={{
|
||||
width: getColumnWidth({
|
||||
column,
|
||||
columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
function getNativeCellVerticalAlignment(verticalAlignment: VerticalAlignment) {
|
||||
switch (verticalAlignment) {
|
||||
case 'self-start':
|
||||
return 'align-top';
|
||||
case 'self-end':
|
||||
return 'align-bottom';
|
||||
case 'self-center':
|
||||
return 'align-middle';
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import type { DocumentTableViewGrid } from '@gitbook/api';
|
||||
|
||||
import { getMergedCellWidth, getTableCellMerge } from './cellMerges';
|
||||
import { getColumnWidth } from './layout';
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import type { TableRecordKV, TableViewProps } from './Table';
|
||||
import type { TableGridViewProps, TableRecordKV } from './Table';
|
||||
import { TableSearchRecord } from './TableSearch';
|
||||
import { getColumnVerticalAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function RecordRow(
|
||||
props: TableViewProps<DocumentTableViewGrid> & {
|
||||
props: TableGridViewProps & {
|
||||
record: TableRecordKV;
|
||||
autoSizedColumns: string[];
|
||||
fixedColumns: string[];
|
||||
}
|
||||
) {
|
||||
const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
|
||||
const { view, record, autoSizedColumns, fixedColumns, block, context, cellMergeLayout } = props;
|
||||
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
|
||||
const firstVisibleColumn = view.columns[0];
|
||||
|
||||
@@ -31,12 +30,22 @@ export function RecordRow(
|
||||
)}
|
||||
>
|
||||
{view.columns.map((column) => {
|
||||
const columnWidth = getColumnWidth({
|
||||
column,
|
||||
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
});
|
||||
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
|
||||
if (cellMerge && !cellMerge.isAnchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderedColumns = cellMerge?.merge.columns ?? [column];
|
||||
const columnWidth = getMergedCellWidth(
|
||||
renderedColumns.map((mergedColumn) =>
|
||||
getColumnWidth({
|
||||
column: mergedColumn,
|
||||
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
})
|
||||
)
|
||||
);
|
||||
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
|
||||
// @ts-expect-error
|
||||
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
|
||||
@@ -45,6 +54,7 @@ export function RecordRow(
|
||||
<div
|
||||
key={column}
|
||||
role="cell"
|
||||
aria-colspan={cellMerge?.merge.colSpan}
|
||||
className={tcls(
|
||||
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
|
||||
'border-tint-subtle',
|
||||
|
||||
@@ -9,6 +9,7 @@ interface StickyViewGridProps {
|
||||
header?: ReactNode;
|
||||
stickyHeader?: boolean;
|
||||
tableClassName?: string;
|
||||
withTableRole?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ function DefaultHeaderScrollGrid({
|
||||
className,
|
||||
header,
|
||||
tableClassName,
|
||||
withTableRole = true,
|
||||
children,
|
||||
}: StickyViewGridProps) {
|
||||
const resolvedTableClassName = tableClassName ?? 'w-fit';
|
||||
@@ -30,7 +32,7 @@ function DefaultHeaderScrollGrid({
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
role="table"
|
||||
role={withTableRole ? 'table' : undefined}
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle"
|
||||
>
|
||||
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
|
||||
@@ -48,6 +50,7 @@ function StickyHeaderOverlayScrollGrid({
|
||||
className,
|
||||
header,
|
||||
tableClassName,
|
||||
withTableRole = true,
|
||||
children,
|
||||
}: StickyViewGridProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -141,7 +144,7 @@ function StickyHeaderOverlayScrollGrid({
|
||||
ref={rootRef}
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
|
||||
data-scrollable="false"
|
||||
role="table"
|
||||
role={withTableRole ? 'table' : undefined}
|
||||
>
|
||||
{header ? (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import type { DocumentBlockTable } from '@gitbook/api';
|
||||
import type { DocumentBlockTable, DocumentTableViewGrid } from '@gitbook/api';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { isBlockOffscreen } from '../utils';
|
||||
import { type TableCellMergeLayout, createTableCellMergeLayout } from './cellMerges';
|
||||
import { getViewGridLayout, hasVisibleHeader } from './layout';
|
||||
import { NativeViewGrid } from './NativeViewGrid';
|
||||
import {
|
||||
type TableRecordKV,
|
||||
getTableCheckboxColumns,
|
||||
@@ -26,6 +28,10 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
|
||||
isOffscreen: boolean;
|
||||
}
|
||||
|
||||
export interface TableGridViewProps extends TableViewProps<DocumentTableViewGrid> {
|
||||
cellMergeLayout: TableCellMergeLayout;
|
||||
}
|
||||
|
||||
export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
const { block, ancestorBlocks, document, context, style } = props;
|
||||
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
|
||||
@@ -45,9 +51,18 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
const searchRecords = showSearch
|
||||
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
|
||||
: [];
|
||||
const cellMergeLayout = createTableCellMergeLayout(
|
||||
block,
|
||||
records.map(([recordId]) => recordId)
|
||||
);
|
||||
|
||||
return (
|
||||
<TableSearchProvider records={searchRecords}>
|
||||
<TableSearchProvider
|
||||
records={searchRecords}
|
||||
recordGroups={
|
||||
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
|
||||
}
|
||||
>
|
||||
<div className={tcls(style, 'flex flex-col gap-3')}>
|
||||
{showSearch ? (
|
||||
<TableSearchInput
|
||||
@@ -55,7 +70,12 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
checkboxColumns={getTableCheckboxColumns(block)}
|
||||
/>
|
||||
) : null}
|
||||
<TableView {...props} isOffscreen={isOffscreen} records={records} />
|
||||
<TableView
|
||||
{...props}
|
||||
isOffscreen={isOffscreen}
|
||||
records={records}
|
||||
cellMergeLayout={cellMergeLayout}
|
||||
/>
|
||||
<TableSearchEmpty />
|
||||
</div>
|
||||
</TableSearchProvider>
|
||||
@@ -68,8 +88,13 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
function TableView({
|
||||
isOffscreen,
|
||||
records,
|
||||
cellMergeLayout,
|
||||
...props
|
||||
}: BlockProps<DocumentBlockTable> & { isOffscreen: boolean; records: TableRecordKV[] }) {
|
||||
}: BlockProps<DocumentBlockTable> & {
|
||||
isOffscreen: boolean;
|
||||
records: TableRecordKV[];
|
||||
cellMergeLayout: TableCellMergeLayout;
|
||||
}) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
switch (block.data.view.type) {
|
||||
@@ -88,6 +113,7 @@ function TableView({
|
||||
view: block.data.view,
|
||||
isOffscreen,
|
||||
records,
|
||||
cellMergeLayout,
|
||||
};
|
||||
const { tableWidth } = getViewGridLayout({
|
||||
block,
|
||||
@@ -102,6 +128,10 @@ function TableView({
|
||||
const withStickyFirstColumn =
|
||||
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
|
||||
|
||||
if (cellMergeLayout.hasVerticalMerges) {
|
||||
return <NativeViewGrid {...gridProps} />;
|
||||
}
|
||||
|
||||
if (withStickyHeader || withStickyFirstColumn) {
|
||||
return (
|
||||
<StickyViewGrid
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { type TableRowRange, tableRowRangesIntersect } from './TableHoverTable';
|
||||
|
||||
const CELL_RANGES = {
|
||||
A: { rowStart: 0, rowEnd: 1 },
|
||||
B: { rowStart: 0, rowEnd: 0 },
|
||||
C: { rowStart: 0, rowEnd: 0 },
|
||||
D: { rowStart: 1, rowEnd: 1 },
|
||||
E: { rowStart: 1, rowEnd: 2 },
|
||||
F: { rowStart: 2, rowEnd: 2 },
|
||||
G: { rowStart: 2, rowEnd: 2 },
|
||||
} satisfies Record<string, TableRowRange>;
|
||||
|
||||
function getHighlightedCells(hoveredCell: keyof typeof CELL_RANGES) {
|
||||
const hoveredRange = CELL_RANGES[hoveredCell];
|
||||
return Object.entries(CELL_RANGES)
|
||||
.filter(([, cellRange]) => tableRowRangesIntersect(cellRange, hoveredRange))
|
||||
.map(([cell]) => cell);
|
||||
}
|
||||
|
||||
describe('tableRowRangesIntersect', () => {
|
||||
it.each([
|
||||
['A', ['A', 'B', 'C', 'D', 'E']],
|
||||
['B', ['A', 'B', 'C']],
|
||||
['D', ['A', 'D', 'E']],
|
||||
['E', ['A', 'D', 'E', 'F', 'G']],
|
||||
] as const)(
|
||||
'highlights cells intersecting %s without transitive expansion',
|
||||
(cell, expected) => {
|
||||
expect(getHighlightedCells(cell)).toEqual([...expected]);
|
||||
}
|
||||
);
|
||||
|
||||
it('treats a horizontal merge as a single-row range', () => {
|
||||
const horizontalMerge = { rowStart: 1, rowEnd: 1 };
|
||||
|
||||
expect(
|
||||
Object.entries(CELL_RANGES)
|
||||
.filter(([, cellRange]) => tableRowRangesIntersect(cellRange, horizontalMerge))
|
||||
.map(([cell]) => cell)
|
||||
).toEqual(['A', 'D', 'E']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent, useRef } from 'react';
|
||||
|
||||
const TABLE_CELL_SELECTOR = 'td[data-table-row-start][data-table-row-end]';
|
||||
|
||||
export interface TableRowRange {
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
}
|
||||
|
||||
export function tableRowRangesIntersect(left: TableRowRange, right: TableRowRange) {
|
||||
return left.rowStart <= right.rowEnd && right.rowStart <= left.rowEnd;
|
||||
}
|
||||
|
||||
/** Applies hover feedback to cells intersecting the hovered cell's original row span. */
|
||||
export function TableHoverTable(props: ComponentPropsWithoutRef<'table'>) {
|
||||
const { onMouseOver, onMouseLeave, ...rest } = props;
|
||||
const hoveredCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||
const highlightedCellsRef = useRef<Set<HTMLTableCellElement>>(new Set());
|
||||
|
||||
const clearHighlightedCells = () => {
|
||||
for (const cell of highlightedCellsRef.current) {
|
||||
cell.removeAttribute('data-table-hovered');
|
||||
}
|
||||
highlightedCellsRef.current.clear();
|
||||
hoveredCellRef.current = null;
|
||||
};
|
||||
|
||||
const handleMouseOver = (event: ReactMouseEvent<HTMLTableElement>) => {
|
||||
onMouseOver?.(event);
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
clearHighlightedCells();
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredCell = target.closest<HTMLTableCellElement>(TABLE_CELL_SELECTOR);
|
||||
if (!hoveredCell || !event.currentTarget.contains(hoveredCell)) {
|
||||
clearHighlightedCells();
|
||||
return;
|
||||
}
|
||||
if (hoveredCellRef.current === hoveredCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredRange = getTableCellRowRange(hoveredCell);
|
||||
if (!hoveredRange) {
|
||||
clearHighlightedCells();
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightedCells = new Set<HTMLTableCellElement>();
|
||||
for (const cell of event.currentTarget.querySelectorAll<HTMLTableCellElement>(
|
||||
TABLE_CELL_SELECTOR
|
||||
)) {
|
||||
const cellRange = getTableCellRowRange(cell);
|
||||
const highlighted =
|
||||
cellRange !== null && tableRowRangesIntersect(cellRange, hoveredRange);
|
||||
cell.toggleAttribute('data-table-hovered', highlighted);
|
||||
if (highlighted) {
|
||||
highlightedCells.add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cell of highlightedCellsRef.current) {
|
||||
if (!highlightedCells.has(cell)) {
|
||||
cell.removeAttribute('data-table-hovered');
|
||||
}
|
||||
}
|
||||
|
||||
hoveredCellRef.current = hoveredCell;
|
||||
highlightedCellsRef.current = highlightedCells;
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event: ReactMouseEvent<HTMLTableElement>) => {
|
||||
onMouseLeave?.(event);
|
||||
clearHighlightedCells();
|
||||
};
|
||||
|
||||
return <table {...rest} onMouseOver={handleMouseOver} onMouseLeave={handleMouseLeave} />;
|
||||
}
|
||||
|
||||
function getTableCellRowRange(cell: HTMLTableCellElement): TableRowRange | null {
|
||||
const rowStart = Number(cell.dataset.tableRowStart);
|
||||
const rowEnd = Number(cell.dataset.tableRowEnd);
|
||||
if (!Number.isInteger(rowStart) || !Number.isInteger(rowEnd) || rowEnd < rowStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { rowStart, rowEnd };
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import React from 'react';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import type { TableCheckboxColumn, TableSelectColumn } from './search';
|
||||
import { type SelectedOptions, recordMatches } from './searchMatch';
|
||||
import {
|
||||
type SelectedOptions,
|
||||
type TableSearchRecordData,
|
||||
getVisibleTableRecordIds,
|
||||
} from './searchMatch';
|
||||
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
@@ -18,15 +22,6 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
* 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 `<TableSearchRecord>`. */
|
||||
id: string;
|
||||
searchText: string;
|
||||
selectValues?: Record<string, string[]>;
|
||||
checkboxValues?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
type TableSearchContextValue = {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
@@ -51,9 +46,10 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
|
||||
*/
|
||||
export function TableSearchProvider(props: {
|
||||
records?: TableSearchRecordData[];
|
||||
recordGroups?: readonly (readonly string[])[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { records = [] } = props;
|
||||
const { records = [], recordGroups = [] } = props;
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
|
||||
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
|
||||
@@ -91,32 +87,18 @@ export function TableSearchProvider(props: {
|
||||
});
|
||||
}, []);
|
||||
|
||||
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<string>();
|
||||
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 visibleIds = React.useMemo(
|
||||
() =>
|
||||
getVisibleTableRecordIds({
|
||||
records,
|
||||
recordGroups,
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns,
|
||||
}),
|
||||
[records, recordGroups, query, selectedOptions, checkedColumns]
|
||||
);
|
||||
|
||||
const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0;
|
||||
|
||||
@@ -310,3 +292,13 @@ export function TableSearchRecord(props: TableSearchRecordProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps a complete native table row group visible when any of its records matches. */
|
||||
export function TableSearchTableBody(
|
||||
props: React.HTMLAttributes<HTMLTableSectionElement> & { recordIds: readonly string[] }
|
||||
) {
|
||||
const { recordIds, children, ...rest } = props;
|
||||
const { visibleIds } = useTableSearch();
|
||||
const matches = visibleIds === null || recordIds.some((recordId) => visibleIds.has(recordId));
|
||||
return matches ? <tbody {...rest}>{children}</tbody> : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { IconsProvider } from '@gitbook/icons';
|
||||
|
||||
import { TranslateContext } from '@/intl/client';
|
||||
import { en } from '@/intl/translations/en';
|
||||
|
||||
mock.module('./RecordCard', () => ({
|
||||
RecordCard: () => <div data-testid="record-card" />,
|
||||
}));
|
||||
|
||||
mock.module('./TableSearch', () => ({
|
||||
TableSearchRecord: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const { ViewCards } = await import('./ViewCards');
|
||||
|
||||
function makeProps(wrap: boolean) {
|
||||
return {
|
||||
block: { data: { fullWidth: false } },
|
||||
view: { type: 'cards', wrap, cardSize: 'medium' },
|
||||
records: [
|
||||
['record-1', {}],
|
||||
['record-2', {}],
|
||||
['record-3', {}],
|
||||
] as any,
|
||||
context: { mode: 'default' },
|
||||
style: undefined,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function renderWithContext(children: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<IconsProvider assetsURL="https://icons.example.com">
|
||||
<TranslateContext.Provider value={en}>{children}</TranslateContext.Provider>
|
||||
</IconsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
expect(markup).toContain('left-0');
|
||||
expect(markup).toContain('ml-8');
|
||||
expect(markup).toContain('right-0');
|
||||
expect(markup).toContain('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');
|
||||
});
|
||||
|
||||
it('renders the wrapping grid by default', () => {
|
||||
const markup = renderWithContext(<ViewCards {...makeProps(true)} />);
|
||||
|
||||
expect(markup).toContain('inline-grid');
|
||||
});
|
||||
});
|
||||
@@ -53,11 +53,9 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
* The carousel layout: cards lay out in a single horizontally-scrolling row that
|
||||
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
|
||||
*
|
||||
* Rather than fading the edges, the row breaks out of the content column so it can
|
||||
* scroll to the page edges. Negative margins on the outer wrapper pull it out; matching
|
||||
* padding + scroll-padding on the scroller keep the first/last cards aligned with the
|
||||
* body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll.
|
||||
* See `bleedVars` below for how far each side reaches.
|
||||
* The row extends by a small, symmetric peek on each side. The matching padding keeps
|
||||
* the first and last cards aligned with the content column while the edge masks fade only
|
||||
* the cards in that peek area.
|
||||
*/
|
||||
function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { view, records } = props;
|
||||
@@ -69,65 +67,10 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]'
|
||||
: 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]';
|
||||
|
||||
// Break the row out of the content column so it bleeds to the page edges instead of
|
||||
// fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the
|
||||
// negative margins (on the wrapper) and the matching padding + scroll-padding (on the
|
||||
// scroller), so the first/last cards stay aligned with the body text at rest while cards
|
||||
// bleed to the edge mid-scroll. Kept as literals here since it's a single block.
|
||||
//
|
||||
// The bleed only makes sense on the default layout, where the 48rem column leaves wide
|
||||
// empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width,
|
||||
// so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the
|
||||
// row past where every other block ends, jutting into the window frame.
|
||||
//
|
||||
// On the default layout:
|
||||
// - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC.
|
||||
// - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space
|
||||
// beside the TOC, so the gap to the viewport edge is `50vw` minus half the column
|
||||
// (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown.
|
||||
// `html` clips horizontal overflow, so a small overshoot is harmless.
|
||||
// - Right collapses to 0 once an outline occupies that column (shown from `xl`), so
|
||||
// cards never slide under it.
|
||||
const bleedVars = tcls(
|
||||
'[--cards-bleed-l:1rem]',
|
||||
'sm:[--cards-bleed-l:1.5rem]',
|
||||
'md:[--cards-bleed-l:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]',
|
||||
'xl:[--cards-bleed-l:3rem]',
|
||||
|
||||
'[--cards-bleed-r:1rem]',
|
||||
'sm:[--cards-bleed-r:1.5rem]',
|
||||
'md:[--cards-bleed-r:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]',
|
||||
'layout-default:xl:[--cards-bleed-r:3rem]',
|
||||
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
|
||||
// Default centered
|
||||
'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]',
|
||||
'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]',
|
||||
|
||||
// Full width, no outline
|
||||
'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]',
|
||||
|
||||
// Full width centered
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]',
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]'
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls(
|
||||
bleedVars,
|
||||
'ml-[calc(var(--cards-bleed-l)*-1)]',
|
||||
'mr-[calc(var(--cards-bleed-r)*-1)]',
|
||||
'xl:transition-[margin]',
|
||||
'hover:z-11'
|
||||
)}
|
||||
className={tcls('-mx-12', '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(
|
||||
@@ -136,21 +79,19 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
'-mt-px',
|
||||
'pb-6',
|
||||
'-mb-6',
|
||||
'pl-[var(--cards-bleed-l)]',
|
||||
'pr-[var(--cards-bleed-r)]',
|
||||
'scroll-pl-[var(--cards-bleed-l)]',
|
||||
'scroll-pr-[var(--cards-bleed-r)]',
|
||||
'px-12',
|
||||
'scroll-px-12',
|
||||
'snap-x',
|
||||
'snap-mandatory',
|
||||
'xl:transition-[padding]'
|
||||
'snap-mandatory'
|
||||
)}
|
||||
scrollByVisibleItems
|
||||
leading={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' },
|
||||
button: { size: 'small', className: 'ml-8' },
|
||||
}}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' },
|
||||
button: { size: 'small', className: 'mr-8' },
|
||||
}}
|
||||
>
|
||||
{records.map((record) => {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { DocumentTableViewGrid } from '@gitbook/api';
|
||||
|
||||
import { getColumnWidth, getViewGridLayout } from './layout';
|
||||
import { RecordRow } from './RecordRow';
|
||||
import type { TableViewProps } from './Table';
|
||||
import type { TableGridViewProps } from './Table';
|
||||
import { getColumnAlignment } from './utils';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
interface ViewGridHeaderProps extends TableViewProps<DocumentTableViewGrid> {
|
||||
interface ViewGridHeaderProps extends TableGridViewProps {
|
||||
className?: string;
|
||||
tableClassName?: string;
|
||||
}
|
||||
|
||||
interface ViewGridProps extends TableViewProps<DocumentTableViewGrid> {
|
||||
interface ViewGridProps extends TableGridViewProps {
|
||||
tableClassName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { DocumentBlockTable, DocumentTableDefinition } from '@gitbook/api';
|
||||
|
||||
import { createTableCellMergeLayout, getMergedCellWidth, getTableCellMerge } from './cellMerges';
|
||||
|
||||
const RECORD_ORDER = ['first', 'second', 'third'];
|
||||
const COLUMN_ORDER = ['a', 'b', 'c'];
|
||||
|
||||
function createBlock(
|
||||
cellMerges?: unknown[],
|
||||
options: {
|
||||
columns?: string[];
|
||||
definitions?: Record<string, DocumentTableDefinition>;
|
||||
recordOrder?: string[];
|
||||
viewType?: 'grid' | 'cards';
|
||||
} = {}
|
||||
) {
|
||||
const columns = options.columns ?? COLUMN_ORDER;
|
||||
const recordOrder = options.recordOrder ?? RECORD_ORDER;
|
||||
const definitions =
|
||||
options.definitions ??
|
||||
Object.fromEntries(columns.map((column) => [column, createDefinition('text', column)]));
|
||||
return {
|
||||
data: {
|
||||
view:
|
||||
options.viewType === 'cards'
|
||||
? { type: 'cards', columns }
|
||||
: { type: 'grid', columns },
|
||||
records: Object.fromEntries(
|
||||
recordOrder.map((recordId, index) => [
|
||||
recordId,
|
||||
{ orderIndex: `${index}`, values: {} },
|
||||
])
|
||||
),
|
||||
definition: definitions,
|
||||
...(cellMerges ? { cellMerges } : {}),
|
||||
},
|
||||
} as unknown as DocumentBlockTable;
|
||||
}
|
||||
|
||||
function createDefinition(type: DocumentTableDefinition['type'], id: string) {
|
||||
const base = { id, title: id };
|
||||
return type === 'text'
|
||||
? ({ ...base, type, textAlignment: 'left' } as DocumentTableDefinition)
|
||||
: ({ ...base, type } as DocumentTableDefinition);
|
||||
}
|
||||
|
||||
describe('createTableCellMergeLayout', () => {
|
||||
it('classifies horizontal and vertical Text and Number merges', () => {
|
||||
for (const type of ['text', 'number'] as const) {
|
||||
const layout = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2), verticalMerge('second', 'c', 2)], {
|
||||
definitions: Object.fromEntries(
|
||||
COLUMN_ORDER.map((column) => [column, createDefinition(type, column)])
|
||||
),
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
|
||||
expect(getTableCellMerge(layout, 'first', 'a')).toMatchObject({
|
||||
isAnchor: true,
|
||||
merge: { rowSpan: 1, colSpan: 2, columns: ['a', 'b'] },
|
||||
});
|
||||
expect(getTableCellMerge(layout, 'first', 'b')).toMatchObject({ isAnchor: false });
|
||||
expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({
|
||||
isAnchor: true,
|
||||
merge: { rowSpan: 2, colSpan: 1, records: ['second', 'third'] },
|
||||
});
|
||||
expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false });
|
||||
expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]);
|
||||
expect(layout.hasVerticalMerges).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats missing metadata and Cards as unmerged layouts', () => {
|
||||
const missing = createTableCellMergeLayout(createBlock(), RECORD_ORDER);
|
||||
const cards = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2)], {
|
||||
viewType: 'cards',
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
|
||||
expect(missing.cells.size).toBe(0);
|
||||
expect(missing.recordGroups).toEqual([['first'], ['second'], ['third']]);
|
||||
expect(missing.hasVerticalMerges).toBe(false);
|
||||
expect(cards.cells.size).toBe(0);
|
||||
expect(cards.recordGroups).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid shape', { anchor: 'first', rowSpan: 1, colSpan: 2 }],
|
||||
['single cell', { anchor: { record: 'first', column: 'a' }, rowSpan: 1, colSpan: 1 }],
|
||||
['unknown record', horizontalMerge('missing', 'a', 2)],
|
||||
['hidden column', horizontalMerge('first', 'hidden', 2)],
|
||||
['out-of-bounds span', horizontalMerge('first', 'c', 2)],
|
||||
['rectangle', { anchor: { record: 'first', column: 'a' }, rowSpan: 2, colSpan: 2 }],
|
||||
])('ignores malformed metadata: %s', (_name, merge) => {
|
||||
const block = createBlock([merge], {
|
||||
definitions: {
|
||||
a: createDefinition('text', 'a'),
|
||||
b: createDefinition('text', 'b'),
|
||||
c: createDefinition('text', 'c'),
|
||||
hidden: createDefinition('text', 'hidden'),
|
||||
},
|
||||
});
|
||||
const layout = createTableCellMergeLayout(block, RECORD_ORDER);
|
||||
|
||||
expect(layout.cells.size).toBe(0);
|
||||
expect(layout.hasVerticalMerges).toBe(false);
|
||||
});
|
||||
|
||||
it('drops every conflicting merge regardless of order while preserving unrelated merges', () => {
|
||||
const horizontal = horizontalMerge('first', 'a', 2);
|
||||
const vertical = verticalMerge('first', 'b', 2);
|
||||
const duplicate = horizontalMerge('first', 'a', 2);
|
||||
const unrelated = verticalMerge('second', 'c', 2);
|
||||
|
||||
for (const cellMerges of [
|
||||
[horizontal, vertical, duplicate, unrelated],
|
||||
[vertical, duplicate, horizontal, unrelated],
|
||||
]) {
|
||||
const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER);
|
||||
|
||||
expect(getTableCellMerge(layout, 'first', 'a')).toBeUndefined();
|
||||
expect(getTableCellMerge(layout, 'first', 'b')).toBeUndefined();
|
||||
expect(getTableCellMerge(layout, 'second', 'b')).toBeUndefined();
|
||||
expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({
|
||||
isAnchor: true,
|
||||
merge: { records: ['second', 'third'], columns: ['c'] },
|
||||
});
|
||||
expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false });
|
||||
expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]);
|
||||
}
|
||||
});
|
||||
|
||||
it('partitions disjoint vertical merges and singleton records in record order', () => {
|
||||
const recordOrder = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth'];
|
||||
const layout = createTableCellMergeLayout(
|
||||
createBlock([verticalMerge('fourth', 'b', 2), verticalMerge('first', 'a', 2)], {
|
||||
recordOrder,
|
||||
}),
|
||||
recordOrder
|
||||
);
|
||||
|
||||
expect(layout.recordGroups).toEqual([
|
||||
['first', 'second'],
|
||||
['third'],
|
||||
['fourth', 'fifth'],
|
||||
['sixth'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('combines transitively connected vertical merges regardless of metadata order', () => {
|
||||
for (const cellMerges of [
|
||||
[verticalMerge('first', 'a', 2), verticalMerge('second', 'b', 2)],
|
||||
[verticalMerge('second', 'b', 2), verticalMerge('first', 'a', 2)],
|
||||
]) {
|
||||
const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER);
|
||||
|
||||
expect(layout.recordGroups).toEqual([['first', 'second', 'third']]);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores merges for unsupported or mixed field types', () => {
|
||||
const unsupportedDefinitions = {
|
||||
a: createDefinition('checkbox', 'a'),
|
||||
b: createDefinition('checkbox', 'b'),
|
||||
};
|
||||
const unsupportedHorizontal = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2)], {
|
||||
columns: ['a', 'b'],
|
||||
definitions: unsupportedDefinitions,
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
const unsupportedVertical = createTableCellMergeLayout(
|
||||
createBlock([verticalMerge('first', 'a', 2)], {
|
||||
columns: ['a', 'b'],
|
||||
definitions: unsupportedDefinitions,
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
const mixed = createTableCellMergeLayout(
|
||||
createBlock([horizontalMerge('first', 'a', 2)], {
|
||||
columns: ['a', 'b'],
|
||||
definitions: {
|
||||
a: createDefinition('text', 'a'),
|
||||
b: createDefinition('number', 'b'),
|
||||
},
|
||||
}),
|
||||
RECORD_ORDER
|
||||
);
|
||||
|
||||
expect(unsupportedHorizontal.cells.size).toBe(0);
|
||||
expect(unsupportedVertical.cells.size).toBe(0);
|
||||
expect(unsupportedVertical.hasVerticalMerges).toBe(false);
|
||||
expect(mixed.cells.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function horizontalMerge(record: string, column: string, colSpan: number) {
|
||||
return { anchor: { record, column }, rowSpan: 1, colSpan };
|
||||
}
|
||||
|
||||
function verticalMerge(record: string, column: string, rowSpan: number) {
|
||||
return { anchor: { record, column }, rowSpan, colSpan: 1 };
|
||||
}
|
||||
|
||||
describe('getMergedCellWidth', () => {
|
||||
it('adds fixed and automatic column widths in CSS', () => {
|
||||
expect(getMergedCellWidth(['120px', 'clamp(100px, calc(100% / 2), 100%)'])).toBe(
|
||||
'calc(120px + clamp(100px, calc(100% / 2), 100%))'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { DocumentBlockTable } from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Temporary compatibility type until the published `@gitbook/api` includes `cellMerges`.
|
||||
*/
|
||||
export interface TableCellMerge {
|
||||
anchor: {
|
||||
record: string;
|
||||
column: string;
|
||||
};
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
type TableDataWithCellMerges = DocumentBlockTable['data'] & {
|
||||
cellMerges?: TableCellMerge[];
|
||||
};
|
||||
|
||||
export interface ResolvedTableCellMerge {
|
||||
records: readonly string[];
|
||||
columns: readonly string[];
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
export interface TableCellMergeSlot {
|
||||
merge: ResolvedTableCellMerge;
|
||||
isAnchor: boolean;
|
||||
}
|
||||
|
||||
export interface TableCellMergeLayout {
|
||||
cells: ReadonlyMap<string, ReadonlyMap<string, TableCellMergeSlot>>;
|
||||
recordGroups: readonly (readonly string[])[];
|
||||
hasVerticalMerges: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve valid merge metadata once for a rendered grid.
|
||||
*
|
||||
* Invalid external metadata is ignored before any cell can be classified as covered, ensuring a
|
||||
* malformed merge always degrades to ordinary cells instead of hiding content.
|
||||
*/
|
||||
export function createTableCellMergeLayout(
|
||||
block: DocumentBlockTable,
|
||||
recordOrder: readonly string[]
|
||||
): TableCellMergeLayout {
|
||||
if (block.data.view.type !== 'grid') {
|
||||
return {
|
||||
cells: new Map(),
|
||||
recordGroups: [],
|
||||
hasVerticalMerges: false,
|
||||
};
|
||||
}
|
||||
|
||||
const columnOrder = block.data.view.columns;
|
||||
const candidates: { merge: ResolvedTableCellMerge; cellKeys: string[] }[] = [];
|
||||
const cells = new Map<string, Map<string, TableCellMergeSlot>>();
|
||||
const verticalRecordGroups: string[][] = [];
|
||||
|
||||
for (const candidate of getRawTableCellMerges(block)) {
|
||||
const merge = resolveTableCellMerge(candidate, block, recordOrder, columnOrder);
|
||||
if (!merge) continue;
|
||||
|
||||
const cellKeys = merge.records.flatMap((recordId) =>
|
||||
merge.columns.map((columnId) => getCellKey(recordId, columnId))
|
||||
);
|
||||
candidates.push({ merge, cellKeys });
|
||||
}
|
||||
|
||||
const cellOwnerCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
for (const cellKey of candidate.cellKeys) {
|
||||
cellOwnerCounts.set(cellKey, (cellOwnerCounts.get(cellKey) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.cellKeys.some((cellKey) => cellOwnerCounts.get(cellKey) !== 1)) continue;
|
||||
const { merge } = candidate;
|
||||
|
||||
for (const recordId of merge.records) {
|
||||
let row = cells.get(recordId);
|
||||
if (!row) {
|
||||
row = new Map();
|
||||
cells.set(recordId, row);
|
||||
}
|
||||
|
||||
for (const columnId of merge.columns) {
|
||||
row.set(columnId, {
|
||||
merge,
|
||||
isAnchor: recordId === merge.records[0] && columnId === merge.columns[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (merge.rowSpan > 1) {
|
||||
verticalRecordGroups.push([...merge.records]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cells,
|
||||
recordGroups: groupConnectedRecords(recordOrder, verticalRecordGroups),
|
||||
hasVerticalMerges: verticalRecordGroups.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the resolved merge classification for a logical table cell. */
|
||||
export function getTableCellMerge(
|
||||
layout: TableCellMergeLayout,
|
||||
recordId: string,
|
||||
columnId: string
|
||||
): TableCellMergeSlot | undefined {
|
||||
return layout.cells.get(recordId)?.get(columnId);
|
||||
}
|
||||
|
||||
/** Combine the independent column widths used by a horizontal merged cell. */
|
||||
export function getMergedCellWidth(widths: string[]): string {
|
||||
return widths.length === 1 && widths[0] ? widths[0] : `calc(${widths.join(' + ')})`;
|
||||
}
|
||||
|
||||
function getRawTableCellMerges(block: DocumentBlockTable): unknown[] {
|
||||
const data = block.data as TableDataWithCellMerges;
|
||||
return Array.isArray(data.cellMerges) ? data.cellMerges : [];
|
||||
}
|
||||
|
||||
function resolveTableCellMerge(
|
||||
candidate: unknown,
|
||||
block: DocumentBlockTable,
|
||||
recordOrder: readonly string[],
|
||||
columnOrder: readonly string[]
|
||||
): ResolvedTableCellMerge | null {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merge = candidate as {
|
||||
anchor?: { record?: unknown; column?: unknown };
|
||||
rowSpan?: unknown;
|
||||
colSpan?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof merge.anchor?.record !== 'string' ||
|
||||
typeof merge.anchor.column !== 'string' ||
|
||||
typeof merge.rowSpan !== 'number' ||
|
||||
!Number.isInteger(merge.rowSpan) ||
|
||||
merge.rowSpan < 1 ||
|
||||
typeof merge.colSpan !== 'number' ||
|
||||
!Number.isInteger(merge.colSpan) ||
|
||||
merge.colSpan < 1 ||
|
||||
!(
|
||||
(merge.rowSpan === 1 && merge.colSpan >= 2) ||
|
||||
(merge.rowSpan >= 2 && merge.colSpan === 1)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recordStart = recordOrder.indexOf(merge.anchor.record);
|
||||
const columnStart = columnOrder.indexOf(merge.anchor.column);
|
||||
if (recordStart < 0 || columnStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const records = recordOrder.slice(recordStart, recordStart + merge.rowSpan);
|
||||
const columns = columnOrder.slice(columnStart, columnStart + merge.colSpan);
|
||||
const recordsValid =
|
||||
records.length === merge.rowSpan &&
|
||||
records.every((recordId) => Boolean(block.data.records[recordId]));
|
||||
const definitions = columns.map((columnId) => block.data.definition[columnId]);
|
||||
const columnsValid =
|
||||
columns.length === merge.colSpan &&
|
||||
definitions.every(
|
||||
(definition) => definition?.type === 'text' || definition?.type === 'number'
|
||||
) &&
|
||||
definitions.every((definition) => definition?.type === definitions[0]?.type);
|
||||
if (!recordsValid || !columnsValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
records: [...records],
|
||||
columns: [...columns],
|
||||
rowSpan: merge.rowSpan,
|
||||
colSpan: merge.colSpan,
|
||||
};
|
||||
}
|
||||
|
||||
function getCellKey(recordId: string, columnId: string): string {
|
||||
return `${recordId.length}:${recordId}${columnId}`;
|
||||
}
|
||||
|
||||
/** Partition records into ordered groups connected by one or more vertical merges. */
|
||||
function groupConnectedRecords(
|
||||
recordOrder: readonly string[],
|
||||
connectedRecordGroups: readonly (readonly string[])[]
|
||||
): string[][] {
|
||||
const parents = new Map(recordOrder.map((recordId) => [recordId, recordId]));
|
||||
|
||||
const findRoot = (recordId: string): string => {
|
||||
const parent = parents.get(recordId);
|
||||
if (!parent || parent === recordId) {
|
||||
return recordId;
|
||||
}
|
||||
|
||||
const root = findRoot(parent);
|
||||
parents.set(recordId, root);
|
||||
return root;
|
||||
};
|
||||
|
||||
for (const group of connectedRecordGroups) {
|
||||
const firstRecordId = group[0];
|
||||
if (!firstRecordId) continue;
|
||||
|
||||
for (const recordId of group.slice(1)) {
|
||||
parents.set(findRoot(recordId), findRoot(firstRecordId));
|
||||
}
|
||||
}
|
||||
|
||||
const groupedRecords = new Map<string, string[]>();
|
||||
for (const recordId of recordOrder) {
|
||||
const root = findRoot(recordId);
|
||||
const group = groupedRecords.get(root);
|
||||
if (group) {
|
||||
group.push(recordId);
|
||||
} else {
|
||||
groupedRecords.set(root, [recordId]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...groupedRecords.values()];
|
||||
}
|
||||
@@ -1,10 +1,34 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { type SelectedOptions, matchesText, recordMatches } from './searchMatch';
|
||||
import {
|
||||
type SelectedOptions,
|
||||
type TableSearchRecordData,
|
||||
getVisibleTableRecordIds,
|
||||
matchesText,
|
||||
recordMatches,
|
||||
} from './searchMatch';
|
||||
|
||||
const NO_OPTIONS: SelectedOptions = {};
|
||||
const NO_CHECKBOXES: ReadonlySet<string> = new Set();
|
||||
|
||||
function visibleIds(
|
||||
records: TableSearchRecordData[],
|
||||
filters: {
|
||||
query?: string;
|
||||
selectedOptions?: SelectedOptions;
|
||||
checkedColumns?: ReadonlySet<string>;
|
||||
},
|
||||
recordGroups: string[][] = []
|
||||
) {
|
||||
return getVisibleTableRecordIds({
|
||||
records,
|
||||
recordGroups,
|
||||
query: filters.query ?? '',
|
||||
selectedOptions: filters.selectedOptions ?? NO_OPTIONS,
|
||||
checkedColumns: filters.checkedColumns ?? NO_CHECKBOXES,
|
||||
});
|
||||
}
|
||||
|
||||
function match(
|
||||
record: {
|
||||
searchText?: string;
|
||||
@@ -149,3 +173,75 @@ describe('recordMatches', () => {
|
||||
expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleTableRecordIds', () => {
|
||||
const records: TableSearchRecordData[] = [
|
||||
{
|
||||
id: 'first',
|
||||
searchText: 'Anchor value',
|
||||
selectValues: { status: ['active'] },
|
||||
checkboxValues: { featured: false },
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
searchText: 'Covered value',
|
||||
selectValues: { status: ['archived'] },
|
||||
checkboxValues: { featured: true },
|
||||
},
|
||||
{
|
||||
id: 'third',
|
||||
searchText: 'Connected value',
|
||||
selectValues: { status: ['pending'] },
|
||||
checkboxValues: { featured: false },
|
||||
},
|
||||
{
|
||||
id: 'unrelated',
|
||||
searchText: 'Unrelated value',
|
||||
selectValues: { status: ['archived'] },
|
||||
checkboxValues: { featured: false },
|
||||
},
|
||||
];
|
||||
const verticalGroup = [['first', 'second']];
|
||||
|
||||
it('returns null when no filter is active', () => {
|
||||
expect(visibleIds(records, {}, verticalGroup)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a complete vertical group when text matches its anchor', () => {
|
||||
expect(visibleIds(records, { query: 'anchor' }, verticalGroup)).toEqual(
|
||||
new Set(['first', 'second'])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a complete vertical group when select or checkbox filters match a grouped row', () => {
|
||||
expect(
|
||||
visibleIds(records, { selectedOptions: { status: new Set(['active']) } }, verticalGroup)
|
||||
).toEqual(new Set(['first', 'second']));
|
||||
expect(
|
||||
visibleIds(records, { checkedColumns: new Set(['featured']) }, verticalGroup)
|
||||
).toEqual(new Set(['second', 'first']));
|
||||
});
|
||||
|
||||
it('excludes completely unmatched and disconnected groups', () => {
|
||||
expect(visibleIds(records, { query: 'unrelated' }, verticalGroup)).toEqual(
|
||||
new Set(['unrelated'])
|
||||
);
|
||||
});
|
||||
|
||||
it('expands transitively connected groups in reversed metadata order without mutation', () => {
|
||||
const groups = [
|
||||
['second', 'third'],
|
||||
['first', 'second'],
|
||||
];
|
||||
const originalGroups = groups.map((group) => [...group]);
|
||||
|
||||
expect(visibleIds(records, { query: 'anchor' }, groups)).toEqual(
|
||||
new Set(['first', 'second', 'third'])
|
||||
);
|
||||
expect(groups).toEqual(originalGroups);
|
||||
});
|
||||
|
||||
it('leaves cards independently filtered when no merge groups are provided', () => {
|
||||
expect(visibleIds(records, { query: 'anchor' })).toEqual(new Set(['first']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,72 @@
|
||||
/** Selected option values per select column, keyed by column id. */
|
||||
export type SelectedOptions = Readonly<Record<string, ReadonlySet<string>>>;
|
||||
|
||||
/** Per-record data used by the client-side table search. */
|
||||
export interface TableSearchRecordData {
|
||||
id: string;
|
||||
searchText: string;
|
||||
selectValues?: Record<string, string[]>;
|
||||
checkboxValues?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match records and expand matching vertical merge groups to a fixed point.
|
||||
*/
|
||||
export function getVisibleTableRecordIds({
|
||||
records,
|
||||
recordGroups = [],
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns,
|
||||
}: {
|
||||
records: readonly TableSearchRecordData[];
|
||||
recordGroups?: readonly (readonly string[])[];
|
||||
query: string;
|
||||
selectedOptions: SelectedOptions;
|
||||
checkedColumns: ReadonlySet<string>;
|
||||
}): ReadonlySet<string> | null {
|
||||
const hasActiveFilters =
|
||||
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
|
||||
if (!hasActiveFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleIds = new Set<string>();
|
||||
for (const record of records) {
|
||||
if (
|
||||
recordMatches(
|
||||
record.searchText,
|
||||
record.selectValues,
|
||||
record.checkboxValues,
|
||||
query,
|
||||
selectedOptions,
|
||||
checkedColumns
|
||||
)
|
||||
) {
|
||||
visibleIds.add(record.id);
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const group of recordGroups) {
|
||||
if (!group.some((id) => visibleIds.has(id))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of group) {
|
||||
if (!visibleIds.has(id)) {
|
||||
visibleIds.add(id);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record passes the current filters.
|
||||
*
|
||||
|
||||
@@ -5,7 +5,7 @@ import QuickLRU from 'quick-lru';
|
||||
import React from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import type { GitSyncState } from '@gitbook/api';
|
||||
import { type GitSyncState, SiteInsightsMarkdownSource } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
|
||||
import { useAIChatController, useAIChatState } from '@/components/AI';
|
||||
@@ -98,9 +98,14 @@ const createCopiedStateStore = () => {
|
||||
set({ copied: true });
|
||||
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
onSuccess?.();
|
||||
timeoutRef = null;
|
||||
|
||||
// Delay resetting the label past the dropdown's closing animation (`scaleOut`,
|
||||
// 200ms) so the "Copied" label doesn't flip back while still visible mid-fade.
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
timeoutRef = null;
|
||||
}, 200);
|
||||
}, 1500);
|
||||
},
|
||||
}));
|
||||
@@ -120,6 +125,10 @@ function useCopiedStore(stateKey: string) {
|
||||
return useStore(getOrCreateCopiedStoreByKey(stateKey));
|
||||
}
|
||||
|
||||
function getReaderMarkdownURL(markdownPageURL: string) {
|
||||
return `${markdownPageURL}?displayAgentInstructions=false&markdownSource=${SiteInsightsMarkdownSource.PageAction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for the markdown version of the page.
|
||||
*/
|
||||
@@ -144,7 +153,7 @@ export function ActionCopyMarkdown(props: {
|
||||
const fetchMarkdown = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
|
||||
const humanURL = getReaderMarkdownURL(markdownPageURL);
|
||||
const result = await fetch(humanURL).then((res) => res.text());
|
||||
markdownCache.set(markdownPageURL, result);
|
||||
|
||||
@@ -153,13 +162,7 @@ export function ActionCopyMarkdown(props: {
|
||||
return result;
|
||||
};
|
||||
|
||||
const onClick = async (e: React.MouseEvent) => {
|
||||
// Prevent default behavior for non-default actions to avoid closing the dropdown.
|
||||
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
|
||||
if (!isDefaultAction) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const onClick = async () => {
|
||||
copy(markdownCache.get(markdownPageURL) || (await fetchMarkdown()), {
|
||||
onSuccess: () => {
|
||||
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
|
||||
@@ -179,6 +182,7 @@ export function ActionCopyMarkdown(props: {
|
||||
description={tString(language, 'copy_page_markdown')}
|
||||
onClick={onClick}
|
||||
loading={loading}
|
||||
closeOnClick={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -196,7 +200,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag
|
||||
icon="markdown"
|
||||
label={tString(language, 'view_page_markdown')}
|
||||
description={tString(language, 'view_page_plaintext')}
|
||||
href={`${markdownPageURL}?displayAgentInstructions=false`}
|
||||
href={getReaderMarkdownURL(markdownPageURL)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -421,9 +425,7 @@ export function CopyToClipboard(props: {
|
||||
icon={copied ? 'check' : icon}
|
||||
label={copied ? tString(language, 'code_copied') : label}
|
||||
description={description}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
onClick={() => {
|
||||
copy(data, {
|
||||
onSuccess: () => {
|
||||
if (type === 'dropdown-menu-item') {
|
||||
@@ -432,6 +434,7 @@ export function CopyToClipboard(props: {
|
||||
},
|
||||
});
|
||||
}}
|
||||
closeOnClick={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -453,6 +456,7 @@ function PageActionWrapper(props: {
|
||||
target?: React.HTMLAttributeAnchorTarget;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
closeOnClick?: boolean;
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
@@ -465,6 +469,7 @@ function PageActionWrapper(props: {
|
||||
description,
|
||||
disabled,
|
||||
loading,
|
||||
closeOnClick,
|
||||
} = props;
|
||||
|
||||
if (type === 'button') {
|
||||
@@ -498,6 +503,7 @@ function PageActionWrapper(props: {
|
||||
target={target}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
closeOnClick={closeOnClick}
|
||||
>
|
||||
<div className="flex size-5 items-center justify-center text-tint">
|
||||
{loading ? (
|
||||
|
||||
@@ -564,6 +564,19 @@ html.dark .highlight-line.diff-deleted .highlight-line-content::before {
|
||||
@apply rounded-none!;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.paragraph {
|
||||
/* Inline action buttons grow to the available width, which needs a flex parent. */
|
||||
&:has(.button, input) {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
&[data-cover-aware-text]:not(:has(.button, input)) {
|
||||
@apply text-contrast-cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Zoomable images */
|
||||
html:has(.zoom-modal) {
|
||||
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- single-value overflow is universal; doiuse flags the whole css-overflow feature */
|
||||
|
||||
@@ -198,14 +198,23 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
const itemKey = getResultKey(item);
|
||||
const shouldAnimateItem =
|
||||
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
|
||||
const handleResultSelect = () => {
|
||||
const handleResultSelect = (
|
||||
event: React.MouseEvent<HTMLAnchorElement>
|
||||
) => {
|
||||
const isPageResult =
|
||||
item.type === 'local-page' ||
|
||||
item.type === 'page' ||
|
||||
item.type === 'record';
|
||||
|
||||
if (
|
||||
query &&
|
||||
siteSpaceId &&
|
||||
(item.type === 'local-page' ||
|
||||
item.type === 'page' ||
|
||||
item.type === 'record')
|
||||
isPageResult &&
|
||||
!event.currentTarget.hash &&
|
||||
event.currentTarget.pathname === window.location.pathname
|
||||
) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (query && siteSpaceId && isPageResult) {
|
||||
addRecentSearchQuery(siteSpaceId, query, 'search');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { scrollByItemsInContainer } from './ScrollContainer';
|
||||
|
||||
type MockRect = {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
class MockElement {
|
||||
constructor(private readonly rect: MockRect) {}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
}
|
||||
}
|
||||
|
||||
const originalHTMLElement = globalThis.HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
value: MockElement,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
value: originalHTMLElement,
|
||||
});
|
||||
});
|
||||
|
||||
function rect(left: number, right: number): MockRect {
|
||||
return { left, right, top: 0, bottom: 100, width: right - left, height: 100 };
|
||||
}
|
||||
|
||||
function makeContainer(
|
||||
childRects: MockRect[],
|
||||
options: { scrollLeft?: number; scrollWidth?: number } = {}
|
||||
) {
|
||||
const scrollCalls: Record<string, unknown>[] = [];
|
||||
const container = Object.assign(new MockElement(rect(0, 300)), {
|
||||
children: childRects.map((childRect) => new MockElement(childRect)),
|
||||
clientHeight: 100,
|
||||
clientWidth: 300,
|
||||
scrollHeight: 100,
|
||||
scrollLeft: options.scrollLeft ?? 0,
|
||||
scrollTop: 0,
|
||||
scrollWidth: options.scrollWidth ?? 1000,
|
||||
scrollTo: (options: Record<string, unknown>) => scrollCalls.push(options),
|
||||
});
|
||||
|
||||
return { container: container as unknown as HTMLElement, scrollCalls };
|
||||
}
|
||||
|
||||
describe('scrollByItemsInContainer', () => {
|
||||
it('advances by fully visible items and excludes a partial preview', () => {
|
||||
const { container, scrollCalls } = makeContainer([
|
||||
rect(0, 100),
|
||||
rect(110, 210),
|
||||
rect(220, 320),
|
||||
rect(330, 430),
|
||||
]);
|
||||
|
||||
scrollByItemsInContainer(container, 'horizontal', 'forward');
|
||||
|
||||
expect(scrollCalls).toEqual([{ top: undefined, left: 220, behavior: 'smooth' }]);
|
||||
});
|
||||
|
||||
it('moves backward by the visible page size', () => {
|
||||
const { container, scrollCalls } = makeContainer(
|
||||
[rect(-220, -120), rect(-110, -10), rect(0, 100), rect(110, 210), rect(220, 320)],
|
||||
{ scrollLeft: 220 }
|
||||
);
|
||||
|
||||
scrollByItemsInContainer(container, 'horizontal', 'backward');
|
||||
|
||||
expect(scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
|
||||
});
|
||||
|
||||
it('clamps at the first and last scroll positions', () => {
|
||||
const firstPage = makeContainer([rect(0, 100), rect(110, 210), rect(220, 320)]);
|
||||
scrollByItemsInContainer(firstPage.container, 'horizontal', 'backward');
|
||||
|
||||
const lastPage = makeContainer(
|
||||
[rect(-240, -140), rect(-130, -30), rect(-20, 80), rect(90, 190), rect(200, 300)],
|
||||
{ scrollLeft: 240, scrollWidth: 540 }
|
||||
);
|
||||
scrollByItemsInContainer(lastPage.container, 'horizontal', 'forward');
|
||||
|
||||
expect(firstPage.scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
|
||||
expect(lastPage.scrollCalls).toEqual([{ top: undefined, left: 240, behavior: 'smooth' }]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,9 @@ export type ScrollContainerProps = {
|
||||
|
||||
/** The ID or ref of the active item to scroll to. */
|
||||
active?: string | React.RefObject<HTMLElement | null>;
|
||||
|
||||
/** Scroll by one page of fully visible direct children instead of one viewport. */
|
||||
scrollByVisibleItems?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function ScrollContainer(props: ScrollContainerProps) {
|
||||
@@ -50,6 +53,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
contentClassName,
|
||||
orientation,
|
||||
active,
|
||||
scrollByVisibleItems = false,
|
||||
leading = { fade: true, button: true },
|
||||
trailing = { fade: true, button: true },
|
||||
...rest
|
||||
@@ -85,6 +89,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollByVisibleItems) {
|
||||
scrollByItemsInContainer(container, orientation, 'forward');
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
|
||||
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
|
||||
@@ -98,6 +107,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollByVisibleItems) {
|
||||
scrollByItemsInContainer(container, orientation, 'backward');
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
|
||||
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
|
||||
@@ -191,6 +205,133 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const FULLY_VISIBLE_EDGE_TOLERANCE_PX = 1;
|
||||
|
||||
/**
|
||||
* Scroll a direct-child track by the number of items currently visible in the snapport.
|
||||
* Scroll padding is excluded from the measurement because it is the carousel's peek area.
|
||||
*/
|
||||
export function scrollByItemsInContainer(
|
||||
container: HTMLElement,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
direction: 'forward' | 'backward'
|
||||
) {
|
||||
const children = Array.from(container.children).filter(
|
||||
(child): child is HTMLElement => child instanceof HTMLElement
|
||||
);
|
||||
const bounds = getScrollBounds(container, orientation);
|
||||
const items = children
|
||||
.map((element, index) => ({ element, index, rect: element.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => {
|
||||
const size = orientation === 'horizontal' ? rect.width : rect.height;
|
||||
return size > 0;
|
||||
})
|
||||
.map((item, index) => ({ ...item, index }));
|
||||
const visibleItems = items.filter(({ rect }) => {
|
||||
const start = orientation === 'horizontal' ? rect.left : rect.top;
|
||||
const end = orientation === 'horizontal' ? rect.right : rect.bottom;
|
||||
return (
|
||||
start >= bounds.start - FULLY_VISIBLE_EDGE_TOLERANCE_PX &&
|
||||
end <= bounds.end + FULLY_VISIBLE_EDGE_TOLERANCE_PX
|
||||
);
|
||||
});
|
||||
|
||||
// A track narrower than its viewport, or one whose children have not laid out yet, should
|
||||
// retain the regular viewport behavior rather than getting stuck at its current position.
|
||||
if (visibleItems.length === 0) {
|
||||
scrollByViewport(container, orientation, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = visibleItems.length;
|
||||
const firstVisibleItem = visibleItems[0];
|
||||
const lastVisibleItem = visibleItems[visibleItems.length - 1];
|
||||
if (!firstVisibleItem || !lastVisibleItem) {
|
||||
scrollByViewport(container, orientation, direction);
|
||||
return;
|
||||
}
|
||||
const targetIndex =
|
||||
direction === 'forward' ? lastVisibleItem.index + 1 : firstVisibleItem.index - pageSize;
|
||||
const maxScroll = getMaxScroll(container, orientation);
|
||||
|
||||
if (targetIndex < 0) {
|
||||
scrollToPosition(container, orientation, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = items.find((item) => item.index === targetIndex);
|
||||
if (!target) {
|
||||
scrollToPosition(container, orientation, maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetStart = orientation === 'horizontal' ? target.rect.left : target.rect.top;
|
||||
const targetPosition =
|
||||
(orientation === 'horizontal' ? container.scrollLeft : container.scrollTop) +
|
||||
targetStart -
|
||||
bounds.start;
|
||||
|
||||
scrollToPosition(container, orientation, Math.min(Math.max(targetPosition, 0), maxScroll));
|
||||
}
|
||||
|
||||
function getScrollBounds(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const computedStyle = typeof window !== 'undefined' ? window.getComputedStyle(container) : null;
|
||||
const leadingPadding = Number.parseFloat(
|
||||
computedStyle?.[orientation === 'horizontal' ? 'scrollPaddingLeft' : 'scrollPaddingTop'] ??
|
||||
''
|
||||
);
|
||||
const trailingPadding = Number.parseFloat(
|
||||
computedStyle?.[
|
||||
orientation === 'horizontal' ? 'scrollPaddingRight' : 'scrollPaddingBottom'
|
||||
] ?? ''
|
||||
);
|
||||
const start = orientation === 'horizontal' ? rect.left : rect.top;
|
||||
const end = orientation === 'horizontal' ? rect.right : rect.bottom;
|
||||
|
||||
return {
|
||||
start: start + (Number.isFinite(leadingPadding) ? leadingPadding : 0),
|
||||
end: end - (Number.isFinite(trailingPadding) ? trailingPadding : 0),
|
||||
};
|
||||
}
|
||||
|
||||
function getMaxScroll(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
|
||||
return Math.max(
|
||||
orientation === 'horizontal'
|
||||
? container.scrollWidth - container.clientWidth
|
||||
: container.scrollHeight - container.clientHeight,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToPosition(
|
||||
container: HTMLElement,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
position: number
|
||||
) {
|
||||
container.scrollTo({
|
||||
top: orientation === 'vertical' ? position : undefined,
|
||||
left: orientation === 'horizontal' ? position : undefined,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
function scrollByViewport(
|
||||
container: HTMLElement,
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
direction: 'forward' | 'backward'
|
||||
) {
|
||||
const position = orientation === 'horizontal' ? container.scrollLeft : container.scrollTop;
|
||||
const distance = orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
|
||||
const maxScroll = getMaxScroll(container, orientation);
|
||||
const target = Math.min(
|
||||
Math.max(position + (direction === 'forward' ? distance : -distance), 0),
|
||||
maxScroll
|
||||
);
|
||||
|
||||
scrollToPosition(container, orientation, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to an element in a container.
|
||||
*/
|
||||
|
||||
@@ -491,4 +491,250 @@ describe('resolveContentRef for direct space links', () => {
|
||||
expect(result?.href).toBe(guideSiteSpace.urls.published!);
|
||||
expect(result?.active).toBe(false);
|
||||
});
|
||||
|
||||
function buildDocumentPage(
|
||||
id: string,
|
||||
title: string,
|
||||
path: string,
|
||||
pages: unknown[] = []
|
||||
): RevisionPageDocument {
|
||||
return {
|
||||
object: 'page',
|
||||
id,
|
||||
type: 'document',
|
||||
kind: 'sheet',
|
||||
title,
|
||||
path,
|
||||
slug: path.split('/').pop() ?? path,
|
||||
pages,
|
||||
tags: [],
|
||||
layout: {},
|
||||
urls: { app: `https://app.gitbook.com/page/${id}` },
|
||||
} as unknown as RevisionPageDocument;
|
||||
}
|
||||
|
||||
function buildRevision(pages: unknown[]): Revision {
|
||||
return {
|
||||
object: 'revision',
|
||||
id: 'rev-space-target',
|
||||
type: 'edits',
|
||||
pages,
|
||||
files: [],
|
||||
reusableContents: [],
|
||||
tags: [],
|
||||
parents: [],
|
||||
createdAt: '',
|
||||
urls: { app: '' },
|
||||
} as unknown as Revision;
|
||||
}
|
||||
|
||||
function buildCrossSpaceContext(
|
||||
targetSpace: Space,
|
||||
targetRevision: Revision,
|
||||
structure: unknown
|
||||
) {
|
||||
const currentSiteSpace = buildSiteSpace(
|
||||
buildSpace('space-current', 'Current Space'),
|
||||
'Current Variant'
|
||||
);
|
||||
const dataFetcher = {
|
||||
getSpace: async ({ spaceId }: { spaceId: string }) =>
|
||||
spaceId === targetSpace.id
|
||||
? { data: targetSpace }
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
getRevision: async ({ spaceId }: { spaceId: string }) =>
|
||||
spaceId === targetSpace.id
|
||||
? { data: targetRevision }
|
||||
: { error: { code: 404, message: 'Not found' } },
|
||||
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
|
||||
withToken: function () {
|
||||
return this;
|
||||
},
|
||||
} as unknown as GitBookDataFetcher;
|
||||
|
||||
return buildContext({
|
||||
siteSpace: currentSiteSpace,
|
||||
structure,
|
||||
dataFetcher,
|
||||
});
|
||||
}
|
||||
|
||||
it('prepends every localized section group and section to cross-space page ancestors', async () => {
|
||||
const targetSpace = buildSpace('space-target', 'Target Space');
|
||||
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
||||
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
||||
const pageGroup = {
|
||||
object: 'page',
|
||||
id: 'page-group',
|
||||
type: 'group',
|
||||
kind: 'group',
|
||||
title: 'Getting started',
|
||||
path: 'getting-started',
|
||||
slug: 'getting-started',
|
||||
pages: [buildDocumentPage('page-target', 'Target page', 'getting-started/target')],
|
||||
};
|
||||
const section = {
|
||||
object: 'site-section',
|
||||
id: 'section-target',
|
||||
title: 'Reference',
|
||||
localizedTitle: { fr: 'Référence' },
|
||||
draft: false,
|
||||
path: 'reference',
|
||||
siteSpaces: [targetSiteSpace],
|
||||
urls: {},
|
||||
};
|
||||
const sectionGroup = {
|
||||
object: 'site-section-group',
|
||||
id: 'section-group-target',
|
||||
title: 'Product documentation',
|
||||
localizedTitle: { fr: 'Documentation produit' },
|
||||
draft: false,
|
||||
sections: [],
|
||||
children: [
|
||||
{
|
||||
object: 'site-section-group',
|
||||
id: 'section-group-nested',
|
||||
title: 'API guides',
|
||||
draft: false,
|
||||
sections: [],
|
||||
children: [
|
||||
{
|
||||
object: 'site-section-group',
|
||||
id: 'section-group-child',
|
||||
title: 'Authentication',
|
||||
localizedTitle: { fr: 'Authentification' },
|
||||
draft: false,
|
||||
sections: [section],
|
||||
children: [section],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
|
||||
type: 'sections',
|
||||
structure: [sectionGroup],
|
||||
});
|
||||
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
||||
{ ...context, locale: 'fr' } as GitBookAnyContext
|
||||
);
|
||||
|
||||
expect(result?.text).toBe('Target page');
|
||||
expect(result?.ancestors).toEqual([
|
||||
{ label: 'Documentation produit' },
|
||||
{ label: 'API guides' },
|
||||
{ label: 'Authentification' },
|
||||
{ label: 'Référence', href: targetSiteSpace.urls.published },
|
||||
{ label: 'Getting started', icon: null, href: expect.any(String) },
|
||||
]);
|
||||
expect(result?.ancestors?.[0]?.href).toBeUndefined();
|
||||
expect(result?.ancestors?.[1]?.href).toBeUndefined();
|
||||
expect(result?.ancestors?.[2]?.href).toBeUndefined();
|
||||
expect(result?.ancestors?.[4]?.href).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses the first document as the target for a page-group reference', async () => {
|
||||
const targetSpace = buildSpace('space-target', 'Target Space');
|
||||
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
||||
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
||||
const pageGroup = {
|
||||
object: 'page',
|
||||
id: 'page-group',
|
||||
type: 'group',
|
||||
kind: 'group',
|
||||
title: 'Learn about Cortex Agentix',
|
||||
path: 'cortex-agentix',
|
||||
slug: 'cortex-agentix',
|
||||
pages: [buildDocumentPage('page-target', 'Cortex Agentix docs', 'cortex-agentix/docs')],
|
||||
};
|
||||
const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
|
||||
type: 'siteSpaces',
|
||||
structure: [targetSiteSpace],
|
||||
});
|
||||
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'page', space: targetSpace.id, page: 'page-group' },
|
||||
context
|
||||
);
|
||||
|
||||
expect(result?.page?.id).toBe('page-target');
|
||||
expect(result?.text).toBe('Learn about Cortex Agentix');
|
||||
expect(result?.ancestors?.map((ancestor) => ancestor.label)).toEqual([
|
||||
'Target Variant',
|
||||
'Learn about Cortex Agentix',
|
||||
]);
|
||||
expect(
|
||||
result?.ancestors?.filter(({ label }) => label === 'Learn about Cortex Agentix')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses the section instead of the variant for an ungrouped cross-space page', async () => {
|
||||
const targetSpace = buildSpace('space-target', 'Target Space');
|
||||
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
||||
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
||||
const section = {
|
||||
object: 'site-section',
|
||||
id: 'section-target',
|
||||
title: 'Reference',
|
||||
draft: false,
|
||||
path: 'reference',
|
||||
siteSpaces: [targetSiteSpace],
|
||||
urls: {},
|
||||
};
|
||||
const context = buildCrossSpaceContext(
|
||||
targetSpace,
|
||||
buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
|
||||
{ type: 'sections', structure: [section] }
|
||||
);
|
||||
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
||||
context
|
||||
);
|
||||
|
||||
expect(result?.ancestors).toEqual([
|
||||
{ label: 'Reference', href: targetSiteSpace.urls.published },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the target variant for a sectionless in-site page', async () => {
|
||||
const targetSpace = buildSpace('space-target', 'Target Space');
|
||||
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
|
||||
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
|
||||
const context = buildCrossSpaceContext(
|
||||
targetSpace,
|
||||
buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
|
||||
{ type: 'siteSpaces', structure: [targetSiteSpace] }
|
||||
);
|
||||
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
||||
context
|
||||
);
|
||||
|
||||
expect(result?.ancestors).toEqual([
|
||||
{ label: 'Target Variant', href: targetSiteSpace.urls.published },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the raw space title for an external page', async () => {
|
||||
const targetSpace = buildSpace('space-external', 'External Space');
|
||||
const context = buildCrossSpaceContext(
|
||||
targetSpace,
|
||||
buildRevision([buildDocumentPage('page-target', 'External page', 'target')]),
|
||||
{ type: 'siteSpaces', structure: [] }
|
||||
);
|
||||
|
||||
const result = await resolveContentRef(
|
||||
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
|
||||
context
|
||||
);
|
||||
|
||||
expect(result?.text).toBe('External page');
|
||||
expect(result?.ancestors).toEqual([
|
||||
{ label: 'External Space', href: targetSpace.urls.published },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,6 +512,42 @@ function getSpaceRefSectionLabel(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ancestors to attach to a resolved content ref. Page/anchor links identify their
|
||||
* containing section instead of repeating the target variant.
|
||||
*/
|
||||
function resolvePageAncestors(
|
||||
context: GitBookAnyContext,
|
||||
contentRef: ContentRef,
|
||||
foundSiteSpace: ReturnType<typeof findSiteSpaceBy>,
|
||||
ctx: { spaceContext: GitBookSpaceContext; baseURL: URL }
|
||||
): { label: string; href?: string }[] {
|
||||
const isPageOrAnchorRef = contentRef.kind === 'page' || contentRef.kind === 'anchor';
|
||||
|
||||
if (isPageOrAnchorRef && foundSiteSpace?.siteSection) {
|
||||
return [
|
||||
...(foundSiteSpace.siteSectionGroups ?? []).map((group) => ({
|
||||
label: getLocalizedTitle(group, context.locale),
|
||||
})),
|
||||
{
|
||||
label: getLocalizedTitle(foundSiteSpace.siteSection, context.locale),
|
||||
href: ctx.baseURL.toString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (foundSiteSpace?.siteSpace) {
|
||||
return [
|
||||
{
|
||||
label: getLocalizedTitle(foundSiteSpace.siteSpace, context.locale),
|
||||
href: ctx.baseURL.toString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [{ label: ctx.spaceContext.space.title, href: ctx.baseURL.toString() }];
|
||||
}
|
||||
|
||||
async function resolveContentRefInSpace(
|
||||
spaceId: string,
|
||||
context: GitBookAnyContext,
|
||||
@@ -546,19 +582,21 @@ async function resolveContentRefInSpace(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the variant title when available, then the section title, then fallback to the space title.
|
||||
const foundSiteSpace =
|
||||
'site' in context
|
||||
? findSiteSpaceBy(context.structure, (siteSpace) => siteSpace.space.id === spaceId)
|
||||
: null;
|
||||
|
||||
const ancestors = resolvePageAncestors(context, contentRef, foundSiteSpace, ctx);
|
||||
|
||||
// Prefer the variant title when available, then the section title, then fallback to the space title for non-page refs.
|
||||
const ancestorLabel = (() => {
|
||||
if ('site' in context) {
|
||||
const currentLanguage = context.locale;
|
||||
const foundSiteSpace = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceId
|
||||
);
|
||||
if (foundSiteSpace?.siteSpace) {
|
||||
return getLocalizedTitle(foundSiteSpace.siteSpace, currentLanguage);
|
||||
return getLocalizedTitle(foundSiteSpace.siteSpace, context.locale);
|
||||
}
|
||||
if (foundSiteSpace?.siteSection) {
|
||||
return getLocalizedTitle(foundSiteSpace.siteSection, currentLanguage);
|
||||
return getLocalizedTitle(foundSiteSpace.siteSection, context.locale);
|
||||
}
|
||||
return ctx.spaceContext.space.title;
|
||||
}
|
||||
@@ -569,10 +607,9 @@ async function resolveContentRefInSpace(
|
||||
return {
|
||||
...resolved,
|
||||
ancestors: [
|
||||
{
|
||||
label: ancestorLabel,
|
||||
href: ctx.baseURL.toString(),
|
||||
},
|
||||
...(contentRef.kind === 'page' || contentRef.kind === 'anchor'
|
||||
? ancestors
|
||||
: [{ label: ancestorLabel, href: ctx.baseURL.toString() }]),
|
||||
...(resolved.ancestors ?? []),
|
||||
].filter(filterOutNullable),
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ export function selectRankAttribute(rank: number): string {
|
||||
}
|
||||
|
||||
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
|
||||
// Option panes must be direct children of the element carrying the set class: the generated
|
||||
// selectors use a child combinator, so a group never resolves the panes of a group nested in it.
|
||||
|
||||
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
|
||||
export const SELECT_GROUP_ATTR = 'data-select-group';
|
||||
|
||||
@@ -73,6 +73,10 @@ function escapeCssString(value: string): string {
|
||||
* chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner
|
||||
* beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}.
|
||||
*
|
||||
* Every rule matches `& > …` rather than a descendant: a nested group's panes are also
|
||||
* descendants of the outer group, so a descendant combinator would let the outer sheet hide them.
|
||||
* The child combinator adds no specificity, leaving the source-order priority above intact.
|
||||
*
|
||||
* Returns `''` for an empty/degenerate set.
|
||||
*/
|
||||
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
|
||||
@@ -86,20 +90,20 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
|
||||
// All rules nest under the scope class; `&` stands in for it (see nesting note above).
|
||||
const rules: string[] = [
|
||||
// Hide every option, then reveal the default. Both are overridden below when a slug is active.
|
||||
`${option}{display:none}`,
|
||||
`[${SELECT_DEFAULT_ATTR}]{display:block}`,
|
||||
`& > ${option}{display:none}`,
|
||||
`& > [${SELECT_DEFAULT_ATTR}]{display:block}`,
|
||||
];
|
||||
|
||||
for (let rank = depth - 1; rank >= 0; rank--) {
|
||||
const attr = selectRankAttribute(rank);
|
||||
const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(',');
|
||||
// When any of the set's options sits at this rank, hide the group's panes...
|
||||
rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`);
|
||||
rules.push(`html:is(${anyAtRank}) & > ${option}{display:none}`);
|
||||
// ...then reveal whichever one matches (correlated, so a per-option list).
|
||||
const show = slugs
|
||||
.map((slug) => {
|
||||
const value = escapeCssString(slug);
|
||||
return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
return `html[${attr}="${value}"] & > [${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
})
|
||||
.join(',');
|
||||
rules.push(`${show}{display:block}`);
|
||||
@@ -112,14 +116,14 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
|
||||
for (const slug of slugs) {
|
||||
const value = escapeCssString(slug);
|
||||
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
rules.push(`html & ${pane} ~ ${pane}{display:none}`);
|
||||
rules.push(`html & > ${pane} ~ ${pane}{display:none}`);
|
||||
}
|
||||
|
||||
// A client click can override that first-match default: it pins the picked pane and unpins its
|
||||
// same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to
|
||||
// first-match since these attributes aren't persisted). Emitted last to win at equal specificity.
|
||||
rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
|
||||
rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
|
||||
rules.push(`html & > ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
|
||||
rules.push(`html & > ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
|
||||
|
||||
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TranslationLanguage } from '@gitbook/api';
|
||||
import { createLinker } from './links';
|
||||
import {
|
||||
filterSiteSpacesByLocale,
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLinkerForSiteSpace,
|
||||
getSiteStructureSections,
|
||||
@@ -79,6 +80,38 @@ describe('site structure traversal', () => {
|
||||
]);
|
||||
expect(listAllSiteSpaces(structure)).toEqual([rootSpace, nestedSpace]);
|
||||
});
|
||||
|
||||
it('returns every section group from the root to the immediate parent', () => {
|
||||
const targetSpace = { id: 'target-space' } as SiteSpace;
|
||||
const targetSection = {
|
||||
object: 'site-section',
|
||||
id: 'target-section',
|
||||
siteSpaces: [targetSpace],
|
||||
} as SiteSection;
|
||||
const childGroup = {
|
||||
object: 'site-section-group',
|
||||
id: 'child-group',
|
||||
children: [targetSection],
|
||||
} as SiteSectionGroup;
|
||||
const firstChildGroup = {
|
||||
object: 'site-section-group',
|
||||
id: 'first-child-group',
|
||||
children: [childGroup],
|
||||
} as SiteSectionGroup;
|
||||
const rootGroup = {
|
||||
object: 'site-section-group',
|
||||
id: 'root-group',
|
||||
children: [firstChildGroup],
|
||||
} as SiteSectionGroup;
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
{ type: 'sections', structure: [rootGroup] },
|
||||
(siteSpace) => siteSpace.id === targetSpace.id
|
||||
);
|
||||
|
||||
expect(found?.siteSectionGroup).toBe(childGroup);
|
||||
expect(found?.siteSectionGroups).toEqual([rootGroup, firstChildGroup, childGroup]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSiteSpacesByLocale', () => {
|
||||
|
||||
@@ -264,6 +264,7 @@ export function findSiteSpaceBy(
|
||||
siteSpace: SiteSpace;
|
||||
siteSection: SiteSection | null;
|
||||
siteSectionGroup: SiteSectionGroup | null;
|
||||
siteSectionGroups: SiteSectionGroup[];
|
||||
} | null {
|
||||
if (siteStructure.type === 'siteSpaces') {
|
||||
const siteSpace = siteStructure.structure.find(predicate) ?? null;
|
||||
@@ -272,6 +273,7 @@ export function findSiteSpaceBy(
|
||||
siteSpace,
|
||||
siteSection: null,
|
||||
siteSectionGroup: null,
|
||||
siteSectionGroups: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,6 +292,7 @@ export function findSiteSpaceBy(
|
||||
siteSpace,
|
||||
siteSection: sectionOrGroup,
|
||||
siteSectionGroup: null,
|
||||
siteSectionGroups: [],
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -361,11 +364,14 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace:
|
||||
function findSiteSpaceByIdInGroupChildren(
|
||||
children: SiteStructureNode[],
|
||||
predicate: (siteSpace: SiteSpace) => boolean,
|
||||
parentGroup: SiteSectionGroup
|
||||
parentGroup: SiteSectionGroup,
|
||||
rootGroup: SiteSectionGroup = parentGroup,
|
||||
sectionGroups: SiteSectionGroup[] = [parentGroup]
|
||||
): {
|
||||
siteSpace: SiteSpace;
|
||||
siteSection: SiteSection;
|
||||
siteSectionGroup: SiteSectionGroup;
|
||||
siteSectionGroups: SiteSectionGroup[];
|
||||
} | null {
|
||||
for (const child of children) {
|
||||
switch (child.object) {
|
||||
@@ -376,12 +382,19 @@ function findSiteSpaceByIdInGroupChildren(
|
||||
siteSpace,
|
||||
siteSection: child,
|
||||
siteSectionGroup: parentGroup,
|
||||
siteSectionGroups: sectionGroups,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'site-section-group': {
|
||||
const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child);
|
||||
const found = findSiteSpaceByIdInGroupChildren(
|
||||
child.children,
|
||||
predicate,
|
||||
child,
|
||||
rootGroup,
|
||||
[...sectionGroups, child]
|
||||
);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SiteInsightsDisplayContext,
|
||||
type SiteInsightsEventLocation,
|
||||
SiteInsightsLLMSVariant,
|
||||
SiteInsightsMarkdownSource,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import {
|
||||
@@ -533,6 +534,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
if (rewrittenURL.searchParams.has('displayAgentInstructions')) {
|
||||
rewrittenURL.searchParams.delete('displayAgentInstructions');
|
||||
}
|
||||
if (rewrittenURL.searchParams.has('markdownSource')) {
|
||||
rewrittenURL.searchParams.delete('markdownSource');
|
||||
}
|
||||
|
||||
const response = NextResponse.rewrite(rewrittenURL, {
|
||||
request: {
|
||||
@@ -897,6 +901,10 @@ function encodePathInSiteContent(
|
||||
// It is encoded as a second path segment (the route is statically rendered, so it can't
|
||||
// read query params at runtime — the question is path-encoded for the same reason).
|
||||
const goal = searchParams.get('goal');
|
||||
// Validated: this is user input going into insights.
|
||||
const markdownSource = Object.values(SiteInsightsMarkdownSource).find(
|
||||
(source) => source === searchParams.get('markdownSource')
|
||||
);
|
||||
return {
|
||||
pathname:
|
||||
typeof ask === 'string'
|
||||
@@ -922,6 +930,7 @@ function encodePathInSiteContent(
|
||||
: [
|
||||
{
|
||||
type: 'page_markdown_request',
|
||||
...(markdownSource ? { markdownSource } : {}),
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user