mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
RND-11830: select store URL param (#4439)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
|
||||
|
||||
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
|
||||
@@ -0,0 +1,238 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias
|
||||
// that the store pulls in — Playwright's loader doesn't resolve it.
|
||||
import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants';
|
||||
import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS';
|
||||
|
||||
/**
|
||||
* Behaviour tests for the `select` CSS: given a recency-ordered selection applied to `<html>`, a
|
||||
* group must show exactly the most-recently-activated of its options (its "first ranking"
|
||||
* selection), falling back to its default when none are active. These run in a real browser against
|
||||
* the actual generated CSS, so they assert observable visibility — not how the selectors are built.
|
||||
*/
|
||||
|
||||
/** Render a single group of option panes with the generated stylesheet. First slug = default. */
|
||||
async function renderGroup(page: Page, slugs: string[]) {
|
||||
const css = generateSelectCSS(slugs);
|
||||
const scope = selectSetClassName(slugs);
|
||||
const panes = slugs
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${css}</style></head><body><div class="${scope}" data-select-group>${panes}</div></body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the recency list to `<html>` as `data-sel-*` attributes (most-recent first), via the shared
|
||||
* attribute-name helper — mirroring what the pre-paint script / store do at runtime.
|
||||
*/
|
||||
async function applySelection(page: Page, active: string[]) {
|
||||
for (const [rank, value] of active.entries()) {
|
||||
await page.evaluate(
|
||||
({ attr, value }) => document.documentElement.setAttribute(attr, value),
|
||||
{ attr: selectRankAttribute(rank), value }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert exactly one pane is visible, and it is the expected slug. */
|
||||
async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) {
|
||||
for (const slug of slugs) {
|
||||
const pane = page.getByTestId(`pane-${slug}`);
|
||||
if (slug === expectedSlug) {
|
||||
await expect(pane).toBeVisible();
|
||||
} else {
|
||||
await expect(pane).toBeHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(page: Page, slugs: string[], active: string[]) {
|
||||
await renderGroup(page, slugs);
|
||||
await applySelection(page, active);
|
||||
}
|
||||
|
||||
test.describe('select CSS visibility', () => {
|
||||
const slugs = ['python', 'go', 'java'];
|
||||
|
||||
test('shows the default when nothing is selected', async ({ page }) => {
|
||||
await setup(page, slugs, []);
|
||||
await expectOnlyVisible(page, slugs, 'python'); // first pane is the default
|
||||
});
|
||||
|
||||
test('shows the selected option and hides the rest', async ({ page }) => {
|
||||
await setup(page, slugs, ['go']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
});
|
||||
|
||||
test('shows the most-recently-activated option of the group', async ({ page }) => {
|
||||
// Recency list is most-recent-first: `go` is more recent than `python`.
|
||||
await setup(page, slugs, ['go', 'python']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
|
||||
await setup(page, slugs, ['python', 'go']);
|
||||
await expectOnlyVisible(page, slugs, 'python');
|
||||
});
|
||||
|
||||
test('ignores more-recent selections that are not in the group', async ({ page }) => {
|
||||
// `dark` is more recent but not one of this group's options, so `go` still wins.
|
||||
await setup(page, slugs, ['dark', 'go', 'python']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
});
|
||||
|
||||
test('falls back to the default when no active slug is in the group', async ({ page }) => {
|
||||
await setup(page, slugs, ['dark', 'light']);
|
||||
await expectOnlyVisible(page, slugs, 'python');
|
||||
});
|
||||
|
||||
test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute
|
||||
// selectors without collapsing together.
|
||||
const symbols = ['c', 'c++', 'c#'];
|
||||
await setup(page, symbols, ['c++']);
|
||||
await expectOnlyVisible(page, symbols, 'c++');
|
||||
});
|
||||
|
||||
test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Two panes share the slug `js`; activating it must reveal only the first, never both.
|
||||
const scope = selectSetClassName(['js', 'ts']);
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default>js 1</div><div data-testid="js-second" data-select-option="js">js 2</div><div data-testid="ts" data-select-option="ts">ts</div></div></body></html>`
|
||||
);
|
||||
await applySelection(page, ['js']);
|
||||
await expect(page.getByTestId('js-first')).toBeVisible();
|
||||
await expect(page.getByTestId('js-second')).toBeHidden();
|
||||
await expect(page.getByTestId('ts')).toBeHidden();
|
||||
});
|
||||
|
||||
test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned;
|
||||
// the pinned one must win over the first-match default.
|
||||
const scope = selectSetClassName(['js', 'ts']);
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default data-select-unpinned>js 1</div><div data-testid="js-second" data-select-option="js" data-select-pinned>js 2</div></div></body></html>`
|
||||
);
|
||||
await applySelection(page, ['js']);
|
||||
await expect(page.getByTestId('js-second')).toBeVisible();
|
||||
await expect(page.getByTestId('js-first')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
interface GroupSpec {
|
||||
id: string;
|
||||
slugs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page
|
||||
* stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit
|
||||
* tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on
|
||||
* `<html>`, most-recent first. This keeps the test focused on the observable behaviour a visitor
|
||||
* sees — a real click switching every group that offers that option — driven by real browser CSS.
|
||||
*/
|
||||
async function renderGroups(page: Page, groups: GroupSpec[]) {
|
||||
const styles = [
|
||||
...new Map(
|
||||
groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)])
|
||||
).values(),
|
||||
]
|
||||
.map((css) => `<style>${css}</style>`)
|
||||
.join('');
|
||||
|
||||
const markup = groups
|
||||
.map((group) => {
|
||||
const scope = selectSetClassName(group.slugs);
|
||||
const buttons = group.slugs
|
||||
.map(
|
||||
(slug) =>
|
||||
`<button data-testid="${group.id}-btn-${slug}" onclick="__select('${slug}')">${slug}</button>`
|
||||
)
|
||||
.join('');
|
||||
const panes = group.slugs
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="${group.id}-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
return `<div class="${scope}" data-select-group><div role="tablist">${buttons}</div>${panes}</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i<cur.length;i++){if(cur[i]!==slug)next.push(cur[i]);}next=next.slice(0,${SELECT_LIST_CAP});for(i=0;i<${SELECT_LIST_CAP};i++){if(next[i])el.setAttribute('data-sel-'+i,next[i]);else el.removeAttribute('data-sel-'+i);}};`;
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head>${styles}<script>${selectScript}</script></head><body>${markup}</body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */
|
||||
async function expectGroupShows(
|
||||
page: Page,
|
||||
groupId: string,
|
||||
slugs: string[],
|
||||
expectedSlug: string
|
||||
) {
|
||||
for (const slug of slugs) {
|
||||
const pane = page.getByTestId(`${groupId}-pane-${slug}`);
|
||||
if (slug === expectedSlug) {
|
||||
await expect(pane).toBeVisible();
|
||||
} else {
|
||||
await expect(pane).toBeHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('select syncing across groups (click-driven)', () => {
|
||||
test('clicking a tab syncs every group offering that option', async ({ page }) => {
|
||||
const slugs = ['python', 'go'];
|
||||
await renderGroups(page, [
|
||||
{ id: 'a', slugs },
|
||||
{ id: 'b', slugs },
|
||||
]);
|
||||
|
||||
// Both groups start on their default (first) pane.
|
||||
await expectGroupShows(page, 'a', slugs, 'python');
|
||||
await expectGroupShows(page, 'b', slugs, 'python');
|
||||
|
||||
// Clicking a tab in group A switches group B too.
|
||||
await page.getByTestId('a-btn-go').click();
|
||||
await expectGroupShows(page, 'a', slugs, 'go');
|
||||
await expectGroupShows(page, 'b', slugs, 'go');
|
||||
|
||||
// And the sync works from either group.
|
||||
await page.getByTestId('b-btn-python').click();
|
||||
await expectGroupShows(page, 'a', slugs, 'python');
|
||||
await expectGroupShows(page, 'b', slugs, 'python');
|
||||
});
|
||||
|
||||
test('only groups that share the clicked option follow along', async ({ page }) => {
|
||||
const shared = ['python', 'go'];
|
||||
const other = ['go', 'rust'];
|
||||
await renderGroups(page, [
|
||||
{ id: 'a', slugs: shared },
|
||||
{ id: 'b', slugs: other },
|
||||
]);
|
||||
|
||||
// `rust` exists only in group B, so clicking it leaves group A on its default.
|
||||
await page.getByTestId('b-btn-rust').click();
|
||||
await expectGroupShows(page, 'b', other, 'rust');
|
||||
await expectGroupShows(page, 'a', shared, 'python');
|
||||
|
||||
// `go` is shared, so clicking it in A moves both groups.
|
||||
await page.getByTestId('a-btn-go').click();
|
||||
await expectGroupShows(page, 'a', shared, 'go');
|
||||
await expectGroupShows(page, 'b', other, 'go');
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
|
||||
@@ -1,213 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithRef,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { type ComponentPropsWithRef, memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { NavigationStatusContext, useListOverflow } from '@/components/hooks';
|
||||
import { useResolvedSlug, useSelect } from '@/components/Select';
|
||||
import { useListOverflow } from '@/components/hooks';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||
import {
|
||||
SELECT_DEFAULT_ATTR,
|
||||
SELECT_GROUP_ATTR,
|
||||
SELECT_OPTION_ATTR,
|
||||
SELECT_PINNED_ATTR,
|
||||
SELECT_UNPINNED_ATTR,
|
||||
} from '@/lib/select';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
};
|
||||
activeTitles: string[];
|
||||
}
|
||||
|
||||
const defaultTabsState: TabsState = {
|
||||
activeIds: {},
|
||||
activeTitles: [],
|
||||
};
|
||||
|
||||
let globalTabsState = getLocalStorageItem('@gitbook/tabsState', defaultTabsState);
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function useTabsState() {
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}, []);
|
||||
|
||||
const getSnapshot = useCallback(() => globalTabsState, []);
|
||||
|
||||
const setTabsState = useCallback((updater: (previous: TabsState) => TabsState) => {
|
||||
globalTabsState = updater(globalTabsState);
|
||||
setLocalStorageItem('@gitbook/tabsState', globalTabsState);
|
||||
listeners.forEach((listener) => listener());
|
||||
}, []);
|
||||
const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return [state, setTabsState] as const;
|
||||
}
|
||||
|
||||
// How many titles are remembered:
|
||||
const TITLES_MAX = 5;
|
||||
|
||||
export interface TabsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
/** The `select` slug for this tab, derived from its title (see Tabs.tsx). */
|
||||
slug: string;
|
||||
icon?: IconName;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
};
|
||||
activeTitles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side component for the tabs, taking care of interactions.
|
||||
*
|
||||
* Pane visibility is driven entirely by CSS (see generateSelectCSS): each pane carries its slug as
|
||||
* `data-select-option`, and the generated stylesheet shows the most-recently-activated one based on
|
||||
* the `data-sel-*` attributes on `<html>`. That means the correct pane is visible before hydration
|
||||
* (no flash) and with JS disabled.
|
||||
*
|
||||
* The one thing CSS can't decide is *which* of several same-named tabs in one group the visitor
|
||||
* clicked — by slug they're identical, so the stylesheet falls back to the first. After an explicit
|
||||
* click we pin the exact pane via `data-select-pinned`/`-unpinned` (a client-only override that
|
||||
* reverts to first-match on reload). The tablist highlight follows the same resolved tab.
|
||||
*/
|
||||
export function DynamicTabs(props: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
setClassName: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { id, tabs, className } = props;
|
||||
const router = useRouter();
|
||||
const { tabs, setClassName, className } = props;
|
||||
const { activate } = useSelect();
|
||||
// The tab the visitor explicitly clicked this session (not persisted — reload reverts to CSS).
|
||||
const [manualId, setManualId] = useState<string | null>(null);
|
||||
|
||||
const { onNavigationClick, hash } = React.useContext(NavigationStatusContext);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [tabsState, setTabsState] = useTabsState();
|
||||
const activeState = useMemo(() => {
|
||||
const input = { id, tabs };
|
||||
return (
|
||||
getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0]
|
||||
);
|
||||
}, [id, tabs, tabsState]);
|
||||
const candidateSlugs = useMemo(() => tabs.map((tab) => tab.slug), [tabs]);
|
||||
const activeSlug = useResolvedSlug(candidateSlugs, tabs[0]?.slug ?? null);
|
||||
|
||||
// Track if the tab has been touched by the user.
|
||||
const touchedRef = useRef(false);
|
||||
// Resolve which tab is shown. Default: the first tab of the active slug — matching the CSS
|
||||
// first-match. A manual click pins a specific tab, but only while its slug is the active one; if
|
||||
// the active slug is a duplicate and the pinned tab isn't the first of it, `override` carries the
|
||||
// pin so the panes below can steer CSS past first-match.
|
||||
const { activeTabId, override } = useMemo(() => {
|
||||
const firstMatch = tabs.find((tab) => tab.slug === activeSlug) ?? tabs[0];
|
||||
const manual = manualId ? tabs.find((tab) => tab.id === manualId) : undefined;
|
||||
const pinned =
|
||||
manual && manual.slug === activeSlug && manual.id !== firstMatch?.id ? manual : null;
|
||||
return { activeTabId: pinned?.id ?? firstMatch?.id ?? null, override: pinned };
|
||||
}, [tabs, activeSlug, manualId]);
|
||||
|
||||
// To avoid issue with hydration, we only use the state from localStorage
|
||||
// once the component has been initialized (=mounted).
|
||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||
// and get stuck into an inconsistent state.
|
||||
const active = initialized ? activeState : tabs[0];
|
||||
|
||||
// When clicking to select a tab, we:
|
||||
// - update the URL hash
|
||||
// - mark this specific ID as selected
|
||||
// - store the ID to auto-select other tabs with the same title
|
||||
const selectTab = useCallback(
|
||||
(tabId: string, manual = true) => {
|
||||
const tab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (!tab) {
|
||||
return;
|
||||
(tabId: string) => {
|
||||
const tab = tabs.find((item) => item.id === tabId);
|
||||
if (tab?.slug) {
|
||||
activate(tab.slug);
|
||||
setManualId(tabId);
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
touchedRef.current = true;
|
||||
const href = `#${tab.id}`;
|
||||
if (window.location.hash !== href) {
|
||||
onNavigationClick(href);
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTabsState((prev) => {
|
||||
if (prev.activeIds[id] === tab.id) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
activeIds: {
|
||||
...prev.activeIds,
|
||||
[id]: tab.id,
|
||||
},
|
||||
activeTitles: tab.title
|
||||
? prev.activeTitles
|
||||
.filter((t) => t !== tab.title)
|
||||
.concat([tab.title])
|
||||
.slice(-TITLES_MAX)
|
||||
: prev.activeTitles,
|
||||
};
|
||||
});
|
||||
},
|
||||
[router, setTabsState, tabs, id]
|
||||
[tabs, activate]
|
||||
);
|
||||
|
||||
// When the hash changes, we try to select the tab containing the targetted element.
|
||||
React.useLayoutEffect(() => {
|
||||
setInitialized(true);
|
||||
|
||||
if (hash) {
|
||||
// First check if the hash matches a tab ID.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
selectTab(hash, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check if the hash matches an element inside a tab.
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabPanel = activeElement.closest('[role="tabpanel"]');
|
||||
if (!tabPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectTab(tabPanel.id, false);
|
||||
}
|
||||
}, [selectTab, tabs, hash]);
|
||||
|
||||
// Scroll to active element in the tab.
|
||||
React.useLayoutEffect(() => {
|
||||
// If there is no hash or active tab, nothing to scroll.
|
||||
if (!hash || hash !== '' || !active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the tab is touched, we don't want to scroll.
|
||||
if (touchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the hash matches a tab, then the scroll is already done.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeElement.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'instant',
|
||||
});
|
||||
}, [active, tabs, hash]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...{ [SELECT_GROUP_ATTR]: '' }}
|
||||
className={tcls(
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-xs',
|
||||
'ring-1 ring-tint-subtle ring-inset',
|
||||
'flex min-w-0 flex-col',
|
||||
setClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
|
||||
<TabItemList tabs={tabs} activeTabId={activeTabId} onSelect={selectTab} />
|
||||
{tabs.map((tab, index) => (
|
||||
<TabPanel
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isDefault={index === 0}
|
||||
pin={
|
||||
override && tab.slug === override.slug
|
||||
? tab.id === override.id
|
||||
? 'pinned'
|
||||
: 'unpinned'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -215,19 +109,24 @@ export function DynamicTabs(props: {
|
||||
|
||||
const TabPanel = memo(function TabPanel(props: {
|
||||
tab: TabsItem;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
pin?: 'pinned' | 'unpinned';
|
||||
}) {
|
||||
const { tab, isActive } = props;
|
||||
const { tab, isDefault, pin } = props;
|
||||
return (
|
||||
<div
|
||||
{...{
|
||||
[SELECT_OPTION_ATTR]: tab.slug,
|
||||
...(isDefault ? { [SELECT_DEFAULT_ATTR]: '' } : {}),
|
||||
...(pin === 'pinned' ? { [SELECT_PINNED_ATTR]: '' } : {}),
|
||||
...(pin === 'unpinned' ? { [SELECT_UNPINNED_ATTR]: '' } : {}),
|
||||
}}
|
||||
role="tabpanel"
|
||||
id={tab.id}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
|
||||
>
|
||||
<div className="p-4" hidden={!isActive}>
|
||||
{tab.body}
|
||||
</div>
|
||||
<div className="p-4">{tab.body}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -438,42 +337,3 @@ function getTabIdFromButtonId(buttonId: string) {
|
||||
}
|
||||
return buttonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicitly selected tab in a set of tabs.
|
||||
*/
|
||||
function getTabBySelection(
|
||||
input: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
},
|
||||
state: TabsState
|
||||
): TabsItem | null {
|
||||
const activeId = state.activeIds[input.id];
|
||||
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best selected tab in a set of tabs by taking only title into account.
|
||||
*/
|
||||
function getTabByTitle(
|
||||
input: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
},
|
||||
state: TabsState
|
||||
): TabsItem | null {
|
||||
return (
|
||||
input.tabs
|
||||
.map((item) => {
|
||||
return {
|
||||
item,
|
||||
score: state.activeTitles.indexOf(item.title),
|
||||
};
|
||||
})
|
||||
.filter(({ score }) => score >= 0)
|
||||
// .sortBy(({ score }) => -score)
|
||||
.sort(({ score: a }, { score: b }) => b - a)
|
||||
.map(({ item }) => item)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { DocumentBlockTabs } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { validateIconName } from '@gitbook/icons/icons';
|
||||
|
||||
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { DynamicTabs, type TabsItem } from './DynamicTabs';
|
||||
import { DynamicTabs } from './DynamicTabs';
|
||||
|
||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
const { block, ancestorBlocks, document, style, context } = props;
|
||||
@@ -15,9 +16,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
throw new Error('Tabs block is missing a key');
|
||||
}
|
||||
|
||||
const id = block.key;
|
||||
|
||||
const tabs: TabsItem[] = block.nodes.map((tab) => {
|
||||
const items = block.nodes.map((tab) => {
|
||||
if (!tab.key) {
|
||||
throw new Error('Tab block is missing a key');
|
||||
}
|
||||
@@ -43,12 +42,72 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
};
|
||||
});
|
||||
|
||||
// When printing, we display the tab, one after the other
|
||||
const tabs = withSelectSlugs(items);
|
||||
|
||||
// When printing, we display the tabs one after the other, each as its own single-tab group so
|
||||
// every variant is visible (no selection to hide them).
|
||||
// When printing we show every tab, one after another, so there's no selection to resolve — skip
|
||||
// the generated stylesheet entirely (each single-tab group's pane is its own default and stays
|
||||
// visible on its own).
|
||||
if (context.mode === 'print') {
|
||||
return tabs.map((tab) => {
|
||||
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
|
||||
});
|
||||
return tabs.map((tab) => (
|
||||
<DynamicTabs
|
||||
key={tab.id}
|
||||
tabs={[tab]}
|
||||
setClassName={selectSetClassName([tab.slug])}
|
||||
className={tcls(style)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
|
||||
const slugs = tabs.map((tab) => tab.slug);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectGroupStyle slugs={slugs} />
|
||||
<DynamicTabs
|
||||
tabs={tabs}
|
||||
setClassName={selectSetClassName(slugs)}
|
||||
className={tcls(style)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stylesheet that resolves which pane a tab group shows, purely in CSS (see generateSelectCSS).
|
||||
* Byte-identical for every visitor, so it has no cache impact.
|
||||
*
|
||||
* `href` + `precedence` opt into React's stylesheet hoisting: the tag is moved to `<head>` (out of
|
||||
* the content flow, so sibling/child selectors like Tailwind's `space-y-*` never count it as a
|
||||
* phantom node) and deduped by `href`, so identical option-sets across the page share one sheet.
|
||||
*/
|
||||
function SelectGroupStyle({ slugs }: { slugs: string[] }) {
|
||||
const css = generateSelectCSS(slugs);
|
||||
if (!css) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<style href={selectSetClassName(slugs)} precedence="high">
|
||||
{css}
|
||||
</style>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
|
||||
* so they stay selectable.
|
||||
*
|
||||
* Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and
|
||||
* on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates
|
||||
* with a positional suffix: that would desync the duplicate and, because the slug rides in the
|
||||
* frozen `?select=` URL, a shared link would silently retarget when tabs are renamed or reordered.
|
||||
*/
|
||||
function withSelectSlugs<T extends { id: string; title: string }>(
|
||||
items: T[]
|
||||
): Array<T & { slug: string }> {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '@/lib/icons/inline';
|
||||
import { defaultCustomization } from '@/lib/utils';
|
||||
import { AnnouncementDismissedScript } from '../Announcement';
|
||||
import { SelectStateScript } from '../Select';
|
||||
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
|
||||
|
||||
function preloadFont(fontData: FontData) {
|
||||
@@ -147,6 +148,9 @@ export async function CustomizationRootLayout(props: {
|
||||
|
||||
<OperatingSystemClassScript />
|
||||
|
||||
{/* Apply the visitor's content selection to <html> before first paint (no flash) */}
|
||||
<SelectStateScript />
|
||||
|
||||
{/* Inject custom font @font-face rules */}
|
||||
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
|
||||
{monospaceFontData.type === 'custom' ? (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { SELECT_URL_PARAM, selectStore } from '@/lib/select';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import type React from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useSelect } from './useSelect';
|
||||
import { useSelectAnchor } from './useSelectAnchor';
|
||||
|
||||
// `useLayoutEffect` runs before paint but warns during SSR (effects don't run on the server anyway),
|
||||
// so fall back to `useEffect` there.
|
||||
const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
function parseSelectParam(value: string | null): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((slug) => slug.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the `select` store to the `?select=` URL param and hydrates it from localStorage. Mounted
|
||||
* once at the site layout level (inside NuqsAdapter). Provides no React context — the store is a
|
||||
* module singleton — so it simply renders its children.
|
||||
*/
|
||||
export function SelectProvider(props: { children: React.ReactNode }) {
|
||||
const [param, setParam] = useQueryState(SELECT_URL_PARAM, parseAsString);
|
||||
const { slugs } = useSelect();
|
||||
// The last value we wrote to the URL, so we can tell our own writes apart from external ones.
|
||||
const mirroredRef = useRef<string | null>(null);
|
||||
|
||||
useSelectAnchor();
|
||||
|
||||
// Adopt whatever the pre-paint script already merged (URL + storage) into the in-memory store.
|
||||
// Layout effect so the store (and the tab highlight it drives) is settled before first paint,
|
||||
// matching the `<html data-sel-*>` the pre-paint script already applied.
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
selectStore.init();
|
||||
}, []);
|
||||
|
||||
// URL → store: a shared link or client-side navigation carrying ?select= prepends its slugs, so
|
||||
// the link wins while the visitor's other preferences survive.
|
||||
useEffect(() => {
|
||||
if (param === mirroredRef.current) {
|
||||
return;
|
||||
}
|
||||
const fromUrl = parseSelectParam(param);
|
||||
if (fromUrl.length > 0) {
|
||||
selectStore.setSlugs([...fromUrl, ...selectStore.getState().slugs]);
|
||||
}
|
||||
}, [param]);
|
||||
|
||||
// store → URL: keep ?select= as a shareable mirror of the recency list (replaceState, no history spam).
|
||||
useEffect(() => {
|
||||
const desired = slugs.length > 0 ? slugs.join(',') : null;
|
||||
if ((param ?? null) === desired) {
|
||||
return;
|
||||
}
|
||||
mirroredRef.current = desired;
|
||||
setParam(desired);
|
||||
}, [slugs, param, setParam]);
|
||||
|
||||
return props.children;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, SELECT_URL_PARAM } from '@/lib/select';
|
||||
import { applySelectStateScript } from './script';
|
||||
|
||||
/**
|
||||
* Inline `<head>` script that applies the visitor's `select` state to `<html>` before first paint,
|
||||
* so the right content variant renders with no flash. Mounted once in the root layout head.
|
||||
*/
|
||||
export function SelectStateScript() {
|
||||
const scriptArgs = JSON.stringify([
|
||||
SELECT_STORAGE_KEY,
|
||||
SELECT_URL_PARAM,
|
||||
SELECT_LIST_CAP,
|
||||
]).slice(1, -1);
|
||||
|
||||
return (
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(${applySelectStateScript.toString()})(${scriptArgs})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SelectStateScript } from './SelectStateScript';
|
||||
export { SelectProvider } from './SelectProvider';
|
||||
export { useSelect, useResolvedSlug } from './useSelect';
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Read the `select` state (URL `?select=` + localStorage) and apply it to `<html>` as `data-sel-N`
|
||||
* attributes as early as possible, so the correct content variant is visible before hydration — no
|
||||
* flash, and it works on cached/static HTML.
|
||||
*
|
||||
* NOTE: this runs in `<head>` before `<body>` exists, and is stringified and injected — so it must be
|
||||
* self-contained (no imports/closures) and touch only `document.documentElement`. The attribute name
|
||||
* and merge rules mirror `lib/select` (`selectRankAttribute`, the store's `normalize`); keep them in
|
||||
* sync. URL slugs are prepended so a shared link wins while the visitor's other preferences survive.
|
||||
*/
|
||||
export function applySelectStateScript(storageKey: string, urlParam: string, cap: number) {
|
||||
try {
|
||||
const slugs: string[] = [];
|
||||
// A Set (not a plain object) so slugs like "constructor"/"toString" aren't treated as
|
||||
// already-seen via Object.prototype — matching the runtime store's dedupe.
|
||||
const seen = new Set<string>();
|
||||
const push = (value: string | null | undefined) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const slug = String(value).trim();
|
||||
if (!slug || seen.has(slug) || slugs.length >= cap) {
|
||||
return;
|
||||
}
|
||||
seen.add(slug);
|
||||
slugs.push(slug);
|
||||
};
|
||||
|
||||
const fromUrl = new URLSearchParams(window.location.search).get(urlParam);
|
||||
if (fromUrl) {
|
||||
const parts = fromUrl.split(',');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
push(parts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const storedStr = window.localStorage.getItem(storageKey);
|
||||
if (storedStr) {
|
||||
const stored = JSON.parse(storedStr);
|
||||
// Only trust a real array — corrupted storage (a string, or an object with `length`)
|
||||
// would otherwise iterate per character/index. Matches the runtime store's handling.
|
||||
if (Array.isArray(stored)) {
|
||||
for (let j = 0; j < stored.length; j++) {
|
||||
push(stored[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const el = document.documentElement;
|
||||
for (let rank = 0; rank < cap; rank++) {
|
||||
const attribute = `data-sel-${rank}`;
|
||||
const slug = slugs[rank];
|
||||
if (slug) {
|
||||
el.setAttribute(attribute, slug);
|
||||
} else {
|
||||
el.removeAttribute(attribute);
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(slugs));
|
||||
} catch {
|
||||
// localStorage blocked (private mode) or malformed state — fall through to block defaults.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { selectStore } from '@/lib/select';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Subscribe to the site-wide `select` state. Returns the current recency list plus the setters.
|
||||
* Consumers that only need "which of my options is active" should prefer {@link useResolvedSlug}.
|
||||
*/
|
||||
export function useSelect() {
|
||||
const slugs = useSyncExternalStore(
|
||||
selectStore.subscribe,
|
||||
selectStore.getState,
|
||||
selectStore.getState
|
||||
).slugs;
|
||||
|
||||
return {
|
||||
slugs,
|
||||
activate: selectStore.activate,
|
||||
deactivate: selectStore.deactivate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which of a block's candidate slugs is active, falling back to `defaultSlug`. Recomputes
|
||||
* whenever the selection changes.
|
||||
*/
|
||||
export function useResolvedSlug(candidateSlugs: string[], defaultSlug: string | null = null) {
|
||||
// `candidateSlugs` is a fresh array each render; key on its contents to keep the snapshot stable.
|
||||
const key = candidateSlugs.join(',');
|
||||
const getResolved = useCallback(() => {
|
||||
const candidates = key ? key.split(',') : [];
|
||||
return selectStore.resolveActiveSlug(candidates) ?? defaultSlug;
|
||||
}, [key, defaultSlug]);
|
||||
return useSyncExternalStore(selectStore.subscribe, getResolved, getResolved);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useHash } from '@/components/hooks';
|
||||
import { SELECT_OPTION_ATTR, selectStore } from '@/lib/select';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Make anchors work across `select` variants: when the URL points at an element inside an inactive
|
||||
* pane (e.g. a heading in a non-selected tab), activate the slugs of its `data-select-option`
|
||||
* ancestors so the pane becomes visible, then scroll to it after the panes reflow.
|
||||
*
|
||||
* Runs once globally (mounted by SelectProvider) so nested groups resolve in a single pass — the
|
||||
* outermost pane is activated first, leaving the innermost (the actual target) most-recent.
|
||||
*/
|
||||
export function useSelectAnchor() {
|
||||
const hash = useHash();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(hash);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slugs: string[] = [];
|
||||
let node: Element | null = target.closest(`[${SELECT_OPTION_ATTR}]`);
|
||||
while (node) {
|
||||
const slug = node.getAttribute(SELECT_OPTION_ATTR);
|
||||
if (slug) {
|
||||
slugs.push(slug);
|
||||
}
|
||||
node = node.parentElement?.closest(`[${SELECT_OPTION_ATTR}]`) ?? null;
|
||||
}
|
||||
|
||||
if (slugs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Outermost first, so the innermost target ends up most-recent and wins in its group.
|
||||
for (let i = slugs.length - 1; i >= 0; i--) {
|
||||
const slug = slugs[i];
|
||||
if (slug) {
|
||||
selectStore.activate(slug);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
});
|
||||
}, [hash]);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { SearchContextProvider } from '../Search';
|
||||
import { SelectProvider } from '../Select';
|
||||
import { useClearRouterCache } from '../hooks/useClearRouterCache';
|
||||
import { LinkContext, type LinkContextType } from '../primitives';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
@@ -62,11 +63,13 @@ export function SiteLayoutClientContexts(props: {
|
||||
storageKey={themeStorageKey}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<LinkContext.Provider value={linkContext}>
|
||||
<SearchContextProvider>
|
||||
<ReducedMotionProvider>{children}</ReducedMotionProvider>
|
||||
</SearchContextProvider>
|
||||
</LinkContext.Provider>
|
||||
<SelectProvider>
|
||||
<LinkContext.Provider value={linkContext}>
|
||||
<SearchContextProvider>
|
||||
<ReducedMotionProvider>{children}</ReducedMotionProvider>
|
||||
</SearchContextProvider>
|
||||
</LinkContext.Provider>
|
||||
</SelectProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* localStorage key holding the visitor's selection: a JSON array of slugs, most-recent-first.
|
||||
* Not namespaced per site — a slug is just a key, so a selection ("python") is meant to follow the
|
||||
* visitor across pages and spaces, exactly like the tabs store it generalizes.
|
||||
*/
|
||||
export const SELECT_STORAGE_KEY = '@gitbook/select';
|
||||
|
||||
/**
|
||||
* Single query parameter carrying shareable selection state, e.g. `?select=python,cloud`
|
||||
* (most-recent-first). A fixed key so author-chosen names never collide with reserved params.
|
||||
*/
|
||||
export const SELECT_URL_PARAM = 'select';
|
||||
|
||||
/**
|
||||
* How many slugs are remembered, most-recent-first. This is also the depth of the CSS "rank ladder"
|
||||
* (see generateSelectCSS): since pane visibility is CSS-only, the ladder must cover every stored
|
||||
* rank, so the two are one knob. The generated CSS is linear in this value, so it's cheap to tune;
|
||||
* 8 comfortably covers realistic stacking of distinct preferences.
|
||||
*/
|
||||
export const SELECT_LIST_CAP = 8;
|
||||
|
||||
/**
|
||||
* Attribute written on `<html>` for the slug at a given recency rank, e.g. `data-sel-0="python"`.
|
||||
* The pre-paint script and the store both write these; the generated CSS reads them.
|
||||
*/
|
||||
export function selectRankAttribute(rank: number): string {
|
||||
return `data-sel-${rank}`;
|
||||
}
|
||||
|
||||
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
|
||||
|
||||
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
|
||||
export const SELECT_GROUP_ATTR = 'data-select-group';
|
||||
/** Carries a pane's slug, e.g. `data-select-option="python"`. */
|
||||
export const SELECT_OPTION_ATTR = 'data-select-option';
|
||||
/** Marks the pane shown when none of the group's slugs are active. */
|
||||
export const SELECT_DEFAULT_ATTR = 'data-select-default';
|
||||
/**
|
||||
* Set by the client on an explicitly-clicked pane to pin it (with `data-select-unpinned` on its
|
||||
* same-slug siblings), overriding the first-match default so the visitor sees exactly the duplicate
|
||||
* they picked. Only applied after a real click — the pre-paint/reload path stays purely CSS-driven.
|
||||
*/
|
||||
export const SELECT_PINNED_ATTR = 'data-select-pinned';
|
||||
export const SELECT_UNPINNED_ATTR = 'data-select-unpinned';
|
||||
/**
|
||||
* Class prefix (followed by a set hash) identifying a distinct candidate-set so identical sets share
|
||||
* one stylesheet. Uses the `gb-` namespace like GitBook's other own classes (`gb-page-cover`, …) to
|
||||
* avoid colliding with author or Tailwind classes; `sel` matches the `data-sel-*` rank attributes.
|
||||
*/
|
||||
export const SELECT_SET_CLASS_PREFIX = 'gb-sel-';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateSelectCSS, selectSetClassName } from './generateSelectCSS';
|
||||
|
||||
// The actual show/hide behaviour of this CSS (most-recent option wins, others hidden, default
|
||||
// fallback) is verified in a real browser in e2e/select.spec.ts. These unit tests only cover the
|
||||
// pure contract of the helpers, independent of how the selectors are constructed.
|
||||
|
||||
describe('selectSetClassName', () => {
|
||||
it('is independent of candidate order', () => {
|
||||
expect(selectSetClassName(['python', 'go'])).toBe(selectSetClassName(['go', 'python']));
|
||||
});
|
||||
|
||||
it('ignores duplicates and empty slugs', () => {
|
||||
expect(selectSetClassName(['python', '', 'python', 'go'])).toBe(
|
||||
selectSetClassName(['go', 'python'])
|
||||
);
|
||||
});
|
||||
|
||||
it('differs for different sets', () => {
|
||||
expect(selectSetClassName(['python', 'go'])).not.toBe(selectSetClassName(['python', 'js']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSelectCSS', () => {
|
||||
it('returns nothing for a degenerate set', () => {
|
||||
expect(generateSelectCSS([])).toBe('');
|
||||
expect(generateSelectCSS(['', ''])).toBe('');
|
||||
});
|
||||
|
||||
it('scopes the generated rules to the set class', () => {
|
||||
const css = generateSelectCSS(['python', 'go']);
|
||||
expect(css).toContain(selectSetClassName(['python', 'go']));
|
||||
});
|
||||
|
||||
it('keeps the safelisted symbols usable in attribute selectors', () => {
|
||||
// `+` and `#` are valid inside a quoted attribute value; they must appear verbatim.
|
||||
const css = generateSelectCSS(['c++', 'c#']);
|
||||
expect(css).toContain('[data-select-option="c++"]');
|
||||
expect(css).toContain('[data-select-option="c#"]');
|
||||
});
|
||||
|
||||
it('escapes CSS-string metacharacters so a widened charset stays well-formed', () => {
|
||||
// slugifySelectValue can't produce these today; this guards future widening.
|
||||
const css = generateSelectCSS(['a"b', 'c\\d']);
|
||||
expect(css).toContain('[data-select-option="a\\"b"]');
|
||||
expect(css).toContain('[data-select-option="c\\\\d"]');
|
||||
// The raw, unescaped quote must never leak into the stylesheet.
|
||||
expect(css).not.toContain('="a"b"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
SELECT_DEFAULT_ATTR,
|
||||
SELECT_LIST_CAP,
|
||||
SELECT_OPTION_ATTR,
|
||||
SELECT_PINNED_ATTR,
|
||||
SELECT_SET_CLASS_PREFIX,
|
||||
SELECT_UNPINNED_ATTR,
|
||||
selectRankAttribute,
|
||||
} from './constants';
|
||||
|
||||
// FNV-1a (32-bit) constants — see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
|
||||
const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
|
||||
const FNV_PRIME_32 = 0x01000193;
|
||||
|
||||
/**
|
||||
* Stable, order-independent hash of a candidate set, so two groups offering the same options
|
||||
* (e.g. `npm`/`yarn`/`pnpm` repeated across a docs site) share one generated stylesheet.
|
||||
*/
|
||||
function hashSlugSet(slugs: string[]): string {
|
||||
const key = [...slugs].sort().join(' ');
|
||||
// FNV-1a: deterministic and dependency-free. Collision risk is irrelevant here since a clash
|
||||
// only means two identical-looking sets share CSS, which is exactly what we want anyway.
|
||||
let hash = FNV_OFFSET_BASIS_32;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash ^= key.charCodeAt(i);
|
||||
hash = Math.imul(hash, FNV_PRIME_32);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* The class a consumer puts on a group element to scope the generated CSS to it. Keyed on the
|
||||
* candidate set (not its order), matching {@link generateSelectCSS}.
|
||||
*/
|
||||
export function selectSetClassName(candidateSlugs: string[]): string {
|
||||
return `${SELECT_SET_CLASS_PREFIX}${hashSlugSet(uniqueSlugs(candidateSlugs))}`;
|
||||
}
|
||||
|
||||
function uniqueSlugs(slugs: string[]): string[] {
|
||||
return [...new Set(slugs.filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a slug for interpolation into a CSS string literal (a quoted attribute-selector value).
|
||||
* `slugifySelectValue` can't currently produce `"` or `\`, so this is defensive — it keeps the
|
||||
* generated CSS well-formed if the slug charset is ever widened.
|
||||
*/
|
||||
function escapeCssString(value: string): string {
|
||||
return value.replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the CSS that makes a group show the most-recently-activated of its options — the same
|
||||
* rule as `resolveActiveSlug`, expressed purely in CSS so it works before hydration and with
|
||||
* JavaScript disabled.
|
||||
*
|
||||
* CSS can't compare two dynamic attributes, but it can compare a dynamic `<html>` rank attribute
|
||||
* (`data-sel-i`, written by the pre-paint script/store) against the set's slugs, which the server
|
||||
* knows as literals. We encode the "most recent wins" priority in **source order** rather than in
|
||||
* selector specificity: rank rules are emitted worst-first (highest rank down to rank 0), so the
|
||||
* most-recent match appears last and wins the cascade among these equal-specificity rules. At each
|
||||
* rank one rule hides the group's panes and the next reveals whichever option sits there, so a lower
|
||||
* (more recent) rank cleanly overrides a higher one. When no option of the set is active anywhere,
|
||||
* the default pane shows.
|
||||
*
|
||||
* A single hide isn't possible: a group can have several of its own options active at once, so each
|
||||
* rank must re-hide to let the most recent win. But the output stays compact via two modern-CSS
|
||||
* levers (both within our Tailwind v4 browser baseline): the whole thing nests under the set's scope
|
||||
* class (`&`) so it isn't repeated in every selector, and the per-rank hide-all — which is
|
||||
* uncorrelated ("hide the group if *any* of its slugs is at this rank") — folds into one `:is()`
|
||||
* selector. The per-rank show can't fold that way (each entry correlates rank-value → option-value,
|
||||
* which `:is()` can't express), so it stays a comma list. Result: `2·depth + 2` rules, no `:not()`
|
||||
* 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}.
|
||||
*
|
||||
* Returns `''` for an empty/degenerate set.
|
||||
*/
|
||||
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
|
||||
const slugs = uniqueSlugs(candidateSlugs);
|
||||
if (slugs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const option = `[${SELECT_OPTION_ATTR}]`;
|
||||
|
||||
// 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}`,
|
||||
];
|
||||
|
||||
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}`);
|
||||
// ...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}"]`;
|
||||
})
|
||||
.join(',');
|
||||
rules.push(`${show}{display:block}`);
|
||||
}
|
||||
|
||||
// Duplicate tab names in one group would otherwise reveal two panes at once. Keep only the first:
|
||||
// hide any option pane preceded by a same-slug sibling. Emitted last and prefixed with `html` so
|
||||
// it beats the show rules above (equal specificity, later source order). The slug stays shared, so
|
||||
// syncing and the `?select=` URL are unaffected — only the second pane's visibility changes.
|
||||
for (const slug of slugs) {
|
||||
const value = escapeCssString(slug);
|
||||
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
|
||||
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}`);
|
||||
|
||||
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './constants';
|
||||
export * from './slug';
|
||||
export * from './generateSelectCSS';
|
||||
export * as selectStore from './store';
|
||||
export type { SelectState } from './store';
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { SLUG_MAX_CODE_POINTS, slugifySelectValue } from './slug';
|
||||
|
||||
describe('slugifySelectValue', () => {
|
||||
// This table IS the frozen public contract for `?select=` URLs — see SLUG_ALGO_VERSION.
|
||||
// Changing any expectation here is a breaking change to already-shared links.
|
||||
const cases: Array<[input: string, expected: string]> = [
|
||||
['Python', 'python'],
|
||||
['JavaScript', 'javascript'],
|
||||
['JS', 'js'], // note: does NOT equal "javascript" — the near-duplicate lint case
|
||||
// Technical symbols in the safelist keep otherwise-colliding names distinct.
|
||||
['C', 'c'],
|
||||
['C++', 'c++'],
|
||||
['C#', 'c#'],
|
||||
['.NET', '.net'],
|
||||
['Node.js', 'node.js'],
|
||||
['on_prem', 'on_prem'],
|
||||
// Letters/numbers/marks from every script survive.
|
||||
['café', 'café'],
|
||||
['naïve', 'naïve'],
|
||||
['安装', '安装'],
|
||||
['日本語', '日本語'],
|
||||
['Ελληνικά', 'ελληνικά'],
|
||||
// Whitespace and other symbols collapse to single dashes and trim.
|
||||
[' npm ', 'npm'],
|
||||
['Two Words', 'two-words'],
|
||||
['on-prem', 'on-prem'],
|
||||
['On-Prem', 'on-prem'],
|
||||
['🚀 Launch', 'launch'],
|
||||
['', ''],
|
||||
['---', ''],
|
||||
['🚀', ''],
|
||||
];
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
it(`${JSON.stringify(input)} → ${JSON.stringify(expected)}`, () => {
|
||||
expect(slugifySelectValue(input)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('drops control/format characters instead of turning them into dashes', () => {
|
||||
const zeroWidthSpace = String.fromCodePoint(0x200b);
|
||||
const nul = String.fromCodePoint(0);
|
||||
expect(slugifySelectValue(`a${zeroWidthSpace}b`)).toBe('ab');
|
||||
expect(slugifySelectValue(`a${nul}b`)).toBe('ab');
|
||||
});
|
||||
|
||||
it('never produces the reserved comma delimiter', () => {
|
||||
expect(slugifySelectValue('a, b, c')).not.toContain(',');
|
||||
});
|
||||
|
||||
describe('length cap', () => {
|
||||
it('caps to SLUG_MAX_CODE_POINTS code points', () => {
|
||||
expect(slugifySelectValue('a'.repeat(200))).toHaveLength(SLUG_MAX_CODE_POINTS);
|
||||
});
|
||||
|
||||
it('counts code points, not UTF-16 units, so astral chars are not cut in half', () => {
|
||||
const astral = '𠀀'; // U+20000, a CJK Extension B ideograph = one surrogate pair
|
||||
const result = slugifySelectValue(astral.repeat(200));
|
||||
expect([...result]).toHaveLength(SLUG_MAX_CODE_POINTS);
|
||||
expect(result).toBe(astral.repeat(SLUG_MAX_CODE_POINTS));
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
for (const [input] of cases) {
|
||||
const once = slugifySelectValue(input);
|
||||
expect(slugifySelectValue(once)).toBe(once);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Version of the slugification algorithm below.
|
||||
*
|
||||
* The slugs it produces are the keys that sync content across the site AND the values that appear
|
||||
* in the public `?select=` URL parameter. Once those URLs are in the wild the algorithm is frozen:
|
||||
* changing it would silently re-resolve links people have already shared. Any future change must
|
||||
* bump this version and be gated behind it, never applied in place.
|
||||
*/
|
||||
export const SLUG_ALGO_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Maximum slug length, counted in code points (not UTF-16 units, so we never cleave a surrogate
|
||||
* pair). Guards against pathological titles — 30 CJK characters is already ~270 bytes once
|
||||
* percent-encoded into `?select=`. Part of the frozen contract (see {@link SLUG_ALGO_VERSION}).
|
||||
*/
|
||||
export const SLUG_MAX_CODE_POINTS = 64;
|
||||
|
||||
/**
|
||||
* Turn an author-typed name (a tab title, button label, picker option…) into a `select` slug.
|
||||
*
|
||||
* DO NOT CHANGE — this is the frozen public `?select=` URL contract (see {@link SLUG_ALGO_VERSION}).
|
||||
* The output must be byte-identical on the server (baking slugs into markup/CSS) and the client
|
||||
* (parsing URLs/storage), so it relies only on locale-independent primitives: Unicode NFKC
|
||||
* normalization + `String.prototype.toLowerCase` (Unicode default case folding, not locale-sensitive).
|
||||
*
|
||||
* It keeps letters, numbers and marks from every script (so `café`, `安装`, `日本語` survive) plus a
|
||||
* small safelist of symbols — `+ # . _` — that distinguish technical names that would otherwise
|
||||
* collide (`c` vs `c++` vs `c#`, `node.js`, `on_prem`). Every other run of characters collapses to a
|
||||
* single `-`, and leading/trailing `-` are trimmed. A slug can never contain the `,` that delimits
|
||||
* `?select=`, and none of these characters need escaping in a URL-encoded query param — but the
|
||||
* safelist widens the set beyond bare word characters, so consumers that interpolate a slug into
|
||||
* another syntax must still escape for it (see the CSS escaping in generateSelectCSS).
|
||||
*
|
||||
* Control, format, bidi and lone-surrogate characters (`\p{C}`) are dropped outright rather than
|
||||
* turned into a `-`, and the string is re-normalized after `toLowerCase` (case mapping can leave it
|
||||
* un-normalized), so the same visible name always yields the same bytes on server and client. The
|
||||
* result is capped to {@link SLUG_MAX_CODE_POINTS} code points. Names that reduce to nothing (e.g. an
|
||||
* emoji-only title) return `''`; callers treat an empty slug as "no slug" and fall back to default.
|
||||
*/
|
||||
export function slugifySelectValue(name: string): string {
|
||||
const slug = name
|
||||
.normalize('NFKC')
|
||||
.replace(/\p{C}+/gu, '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}\p{M}+#._]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
// Slice by code point so a surrogate pair (e.g. astral CJK) is never cut in half, then re-trim a
|
||||
// trailing `-` the cut may have exposed.
|
||||
return [...slug].slice(0, SLUG_MAX_CODE_POINTS).join('').replace(/-+$/u, '');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { SELECT_LIST_CAP } from './constants';
|
||||
import { activate, deactivate, getState, resolveActiveSlug, setSlugs, subscribe } from './store';
|
||||
|
||||
beforeEach(() => {
|
||||
setSlugs([]);
|
||||
});
|
||||
|
||||
describe('select store', () => {
|
||||
it('activates slugs most-recent-first', () => {
|
||||
activate('python');
|
||||
activate('cloud');
|
||||
expect(getState().slugs).toEqual(['cloud', 'python']);
|
||||
});
|
||||
|
||||
it('moves an already-active slug back to the front', () => {
|
||||
setSlugs(['go', 'python', 'cloud']);
|
||||
activate('cloud');
|
||||
expect(getState().slugs).toEqual(['cloud', 'go', 'python']);
|
||||
});
|
||||
|
||||
it('dedupes and drops empty slugs', () => {
|
||||
setSlugs(['python', '', 'python', 'go']);
|
||||
expect(getState().slugs).toEqual(['python', 'go']);
|
||||
});
|
||||
|
||||
it('caps the list and evicts the oldest', () => {
|
||||
const many = Array.from({ length: SELECT_LIST_CAP + 5 }, (_, i) => `s${i}`);
|
||||
setSlugs(many);
|
||||
expect(getState().slugs).toHaveLength(SELECT_LIST_CAP);
|
||||
expect(getState().slugs).toEqual(many.slice(0, SELECT_LIST_CAP));
|
||||
});
|
||||
|
||||
it('deactivates a slug', () => {
|
||||
setSlugs(['python', 'cloud']);
|
||||
deactivate('python');
|
||||
expect(getState().slugs).toEqual(['cloud']);
|
||||
});
|
||||
|
||||
describe('resolveActiveSlug', () => {
|
||||
it('returns the most recently active candidate', () => {
|
||||
setSlugs(['cloud', 'python', 'go']);
|
||||
expect(resolveActiveSlug(['go', 'python'])).toBe('python');
|
||||
});
|
||||
|
||||
it('returns null when no candidate is active', () => {
|
||||
setSlugs(['cloud']);
|
||||
expect(resolveActiveSlug(['python', 'go'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('change notifications', () => {
|
||||
it('notifies subscribers on a real change', () => {
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribe(() => {
|
||||
calls++;
|
||||
});
|
||||
activate('python');
|
||||
expect(calls).toBe(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('does not notify when the list is unchanged (prevents URL mirror loops)', () => {
|
||||
setSlugs(['python', 'go']);
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribe(() => {
|
||||
calls++;
|
||||
});
|
||||
setSlugs(['python', 'go']);
|
||||
activate('python'); // already at front → no change
|
||||
expect(calls).toBe(0);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, selectRankAttribute } from './constants';
|
||||
|
||||
/**
|
||||
* The one piece of `select` state: a site-wide, recency-ordered list of active slugs
|
||||
* (most-recent-first, deduped, capped). Setters `activate`/`deactivate` slugs; consumers read the
|
||||
* list through `resolveActiveSlug`. Everything is client-side — the store is never rendered into
|
||||
* SSR HTML, so pages stay byte-identical for every visitor.
|
||||
*/
|
||||
export interface SelectState {
|
||||
slugs: string[];
|
||||
}
|
||||
|
||||
let state: SelectState = { slugs: [] };
|
||||
const listeners = new Set<() => void>();
|
||||
let initialized = false;
|
||||
|
||||
/** Current selection. Server-side this is always the empty list. */
|
||||
export function getState(): SelectState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Subscribe to selection changes. Returns an unsubscribe function. */
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Dedupe, drop empties, and cap to the most-recent {@link SELECT_LIST_CAP} slugs. */
|
||||
function normalize(slugs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const slug of slugs) {
|
||||
if (!slug || seen.has(slug)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(slug);
|
||||
out.push(slug);
|
||||
if (out.length >= SELECT_LIST_CAP) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameList(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((slug, index) => slug === b[index]);
|
||||
}
|
||||
|
||||
function commit(nextSlugs: string[]) {
|
||||
const slugs = normalize(nextSlugs);
|
||||
// No-op when nothing changed — this is what keeps the store⇄URL mirror from looping.
|
||||
if (sameList(slugs, state.slugs)) {
|
||||
return;
|
||||
}
|
||||
state = { slugs };
|
||||
setLocalStorageItem(SELECT_STORAGE_KEY, slugs);
|
||||
mirrorToHtml(slugs);
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a slug to the front (most-recent). No-op for an empty slug. */
|
||||
export function activate(slug: string) {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
commit([slug, ...state.slugs]);
|
||||
}
|
||||
|
||||
/** Remove a slug from the list (used by filters). */
|
||||
export function deactivate(slug: string) {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
commit(state.slugs.filter((s) => s !== slug));
|
||||
}
|
||||
|
||||
/** Replace the whole list (used when hydrating from URL + storage). */
|
||||
export function setSlugs(slugs: string[]) {
|
||||
commit(slugs);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single shared resolution rule every consumer uses: given the slugs a block contains, return
|
||||
* the one that is most recently active (smallest index), or `null` so the caller can fall back to
|
||||
* its own default.
|
||||
*/
|
||||
export function resolveActiveSlug(candidates: string[]): string | null {
|
||||
let best: string | null = null;
|
||||
let bestIndex = Number.POSITIVE_INFINITY;
|
||||
for (const candidate of candidates) {
|
||||
const index = state.slugs.indexOf(candidate);
|
||||
if (index >= 0 && index < bestIndex) {
|
||||
bestIndex = index;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the in-memory store from localStorage (once per full page load). The pre-paint script has
|
||||
* already merged `?select=` into storage and written `<html>` before this runs, so we just adopt it;
|
||||
* we re-mirror to `<html>` too, to stay correct after a client-side navigation.
|
||||
*/
|
||||
export function init() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
const stored = getLocalStorageItem<string[]>(SELECT_STORAGE_KEY, []);
|
||||
state = { slugs: normalize(Array.isArray(stored) ? stored : []) };
|
||||
mirrorToHtml(state.slugs);
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the recency list onto `<html>` as `data-sel-0…N` attributes — the same attributes the
|
||||
* pre-paint script writes — so runtime updates re-drive the exact CSS that handled the first paint.
|
||||
*/
|
||||
function mirrorToHtml(slugs: string[]) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const el = document.documentElement;
|
||||
for (let rank = 0; rank < SELECT_LIST_CAP; rank++) {
|
||||
const attribute = selectRankAttribute(rank);
|
||||
const slug = slugs[rank];
|
||||
if (slug) {
|
||||
el.setAttribute(attribute, slug);
|
||||
} else {
|
||||
el.removeAttribute(attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user