mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-18 16:45:11 +00:00
Honour an explicit select slug on a tab (#4601)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Let a tab keep its content selection across a rename, by honouring an explicit slug set on the tab instead of always deriving one from its title.
|
||||
@@ -5,7 +5,12 @@ import { validateIconName } from '@gitbook/icons/icons';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { DynamicTabs } from './DynamicTabs';
|
||||
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
|
||||
import {
|
||||
generateSelectCSS,
|
||||
resolveSelectSlug,
|
||||
selectSetClassName,
|
||||
slugifySelectValue,
|
||||
} from '@/lib/select';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
@@ -26,6 +31,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
return {
|
||||
id: tab.meta?.id ?? tab.key,
|
||||
title: tab.data.title ?? '',
|
||||
slug: tab.data.slug,
|
||||
icon,
|
||||
body: (
|
||||
<Blocks
|
||||
@@ -94,19 +100,20 @@ function SelectGroupStyle({ slugs }: { slugs: string[] }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
|
||||
* so they stay selectable.
|
||||
* Resolve a `select` slug for each tab: its explicit slug when set, else one derived 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 make a stored selection retarget
|
||||
* whenever tabs are renamed or reordered.
|
||||
* whenever tabs are renamed or reordered. An explicit slug is the way out for a tab that needs to
|
||||
* keep its identity across a rename.
|
||||
*/
|
||||
function withSelectSlugs<T extends { id: string; title: string }>(
|
||||
function withSelectSlugs<T extends { id: string; title: string; slug?: string }>(
|
||||
items: T[]
|
||||
): (T & { slug: string })[] {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
|
||||
slug: resolveSelectSlug(item) || slugifySelectValue(item.id) || item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { resolveSelectSlug } from './slug';
|
||||
|
||||
// Mirrors packages/doc-core/src/select/__tests__/resolveSelectSlug.test.ts in the gitbook-x repo, so
|
||||
// a tab resolves to the same slug in the editor preview as it does on the published site.
|
||||
describe('resolveSelectSlug', () => {
|
||||
it('prefers an explicit slug over the title', () => {
|
||||
expect(resolveSelectSlug({ slug: 'macos', title: 'macOS 15' })).toBe('macos');
|
||||
});
|
||||
|
||||
it('slugifies an explicit slug, since it reaches CSS selectors and stored state', () => {
|
||||
expect(resolveSelectSlug({ slug: 'On Prem!', title: 'Self hosted' })).toBe('on-prem');
|
||||
});
|
||||
|
||||
it('derives from the title when no slug is set', () => {
|
||||
expect(resolveSelectSlug({ title: 'macOS 15' })).toBe('macos-15');
|
||||
});
|
||||
|
||||
it('keeps titles syncing across a site: same title, same slug', () => {
|
||||
expect(resolveSelectSlug({ title: 'macOS' })).toBe(resolveSelectSlug({ title: 'macOS' }));
|
||||
});
|
||||
|
||||
it('falls back to the title when the slug is empty or unaddressable', () => {
|
||||
expect(resolveSelectSlug({ slug: '', title: 'Windows' })).toBe('windows');
|
||||
// An emoji-only slug reduces to nothing, which means "not set", not "clear the slug".
|
||||
expect(resolveSelectSlug({ slug: '🎉', title: 'Windows' })).toBe('windows');
|
||||
});
|
||||
|
||||
it('returns an empty slug when neither field is addressable', () => {
|
||||
expect(resolveSelectSlug({})).toBe('');
|
||||
expect(resolveSelectSlug({ title: '🎉' })).toBe('');
|
||||
expect(resolveSelectSlug({ slug: '🎉', title: '🎉' })).toBe('');
|
||||
});
|
||||
|
||||
it('is idempotent, so a resolved slug can be fed back in', () => {
|
||||
const once = resolveSelectSlug({ title: 'macOS 15' });
|
||||
expect(resolveSelectSlug({ slug: once })).toBe(once);
|
||||
});
|
||||
});
|
||||
@@ -49,3 +49,21 @@ export function slugifySelectValue(name: string): string {
|
||||
// trailing `-` the cut may have exposed.
|
||||
return [...slug].slice(0, SLUG_MAX_CODE_POINTS).join('').replace(/-+$/u, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `select` slug for one option — a tab, a variant, a picker entry.
|
||||
*
|
||||
* An explicit slug wins but is still slugified, since it reaches generated CSS attribute selectors
|
||||
* and is compared against stored state. Falling back to the title is what lets same-named options
|
||||
* across a site select together with no authoring, so an absent slug is the normal case.
|
||||
*
|
||||
* DUPLICATED, ON PURPOSE, alongside {@link slugifySelectValue}: the editor owns the same function at
|
||||
* `packages/doc-core/src/select/slug.ts` in the gitbook-x repo, and the two must agree or a tab
|
||||
* resolves differently in the editor preview than on the published site.
|
||||
*/
|
||||
export function resolveSelectSlug(option: { slug?: string; title?: string }): string {
|
||||
return (
|
||||
(option.slug ? slugifySelectValue(option.slug) : '') ||
|
||||
(option.title ? slugifySelectValue(option.title) : '')
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user