Skip to content

Commit 798c10d

Browse files
committed
Add variant support to the page-toc
1 parent 9e40b72 commit 798c10d

9 files changed

Lines changed: 189 additions & 15 deletions

File tree

‎frontend/purestack.studio/components/site/page-toc/page-toc.mdx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,22 @@ export async function writePageTocPreview(site: SiteConfig) {
146146
</TabPane>
147147
</Tabs>
148148

149+
## From site config
150+
151+
The table of contents the doc layout renders takes its tone, variant and extra classes from the `pageToc` section of `siteConfig.json`. A page can still override the tone with `layout.tocTone` in its frontmatter.
152+
153+
```json
154+
{
155+
"pageToc": {
156+
"tone": "neutral",
157+
"variant": "surfaceAlt",
158+
"class": "spotlight-from-top-right"
159+
}
160+
}
161+
```
162+
163+
The panel keeps its own rounded corners whatever the variant, and drops its border when it collapses to a rail.
164+
149165
## Behavior and accessibility
150166

151167
The default doc template already renders the page TOC; do not add a second copy to a normal guide. A sole h1 outline root is omitted so the list starts at its children. The template renders top-level entries and one nested level. Panel controls belong to the standard doc layout.
@@ -161,6 +177,8 @@ The default doc template already renders the page TOC; do not add a second copy
161177
<a href="#page-toc-prop-items">items</a>
162178
<a href="#page-toc-prop-title">title</a>
163179
<a href="#page-toc-prop-tone">tone</a>
180+
<a href="#page-toc-prop-variant">variant</a>
181+
<a href="#page-toc-prop-variantMode">variantMode</a>
164182
</nav>
165183

166184
<ApiProperty anchor="page-toc-prop-items" name="items" valueType="PageOutlineItem[]" defaultValue="context.outline">
@@ -171,10 +189,18 @@ The default doc template already renders the page TOC; do not add a second copy
171189
<p>Trimmed visible panel title. Empty text uses the default.</p>
172190
</ApiProperty>
173191

174-
<ApiProperty anchor="page-toc-prop-tone" name="tone" valueType="SemanticTone" defaultValue="inherited">
192+
<ApiProperty anchor="page-toc-prop-tone" name="tone" valueType="RefOrValue&lt;SemanticTone&gt;" defaultValue="inherited">
175193
<p>Semantic intent: neutral, accent, secondary, info, success, warning, danger, feature, custom or ghost. The active skin supplies the colors.</p>
176194
</ApiProperty>
177195

196+
<ApiProperty anchor="page-toc-prop-variant" name="variant" valueType="RefOrValue&lt;ComponentVariant&gt;" defaultValue="flat">
197+
<p>Visual treatment of the panel. Accepts solid, surface, surfaceAlt, spotlight, glass, flat, flatAlt, flatSolid, outlineFill, outline, subtle, subtleBtn, link, sheen, underline, rail, bracket or none. The panel keeps its own rounded corners whatever the variant.</p>
198+
</ApiProperty>
199+
200+
<ApiProperty anchor="page-toc-prop-variantMode" name="variantMode" valueType="RefOrValue&lt;ComponentVariantMode&gt;" defaultValue="stateless">
201+
<p>Use stateless for the contents panel; stateful enables the treatment’s hover and active styles on the whole panel. Links keep their own hover and active styles either way.</p>
202+
</ApiProperty>
203+
178204
### Composition
179205

180206
<div class="api-contract">

‎packages/ts-common/src/site-config-types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export interface DocLayoutConfig {
4343
export interface PageTocConfig {
4444
enabled: boolean
4545
tone: SemanticTone
46+
/** Treatment of the table of contents panel, such as flat, surfaceAlt or glass. */
47+
variant?: string
48+
/** Extra classes for the table of contents panel. */
49+
class?: string
4650
}
4751

4852
export interface AuthConfig {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ensureDomGlobals } from '@purestack/ts-minidom'
2+
import { renderApp } from '@purestack/ts-render'
3+
import { getSvgIcon } from '@purestack/ts-svg-icons'
4+
import { describe, expect, it } from 'vitest'
5+
import { createTestContext } from '../../test/testContext'
6+
import { defineButtonComponents } from '../btn/btn'
7+
import { defineIconComponents } from '../icon/icon'
8+
import { definePageTocComponents } from './pageToc'
9+
10+
function renderPageToc(template: string) {
11+
const cleanup = ensureDomGlobals()
12+
const html = renderApp(template, {
13+
components: {
14+
...defineButtonComponents(),
15+
...defineIconComponents(getSvgIcon),
16+
...definePageTocComponents(),
17+
},
18+
context: createTestContext({
19+
outline: [{ id: 'intro', title: 'Intro', depth: 2 }],
20+
}),
21+
})
22+
cleanup()
23+
return html.match(/<nav class="([^"]*)"/)?.[1].split(' ') ?? []
24+
}
25+
26+
describe('PageToc rendering', () => {
27+
it('renders a stateless flat panel by default', () => {
28+
const classes = renderPageToc(`<PageToc/>`)
29+
30+
expect(classes).toContain('page-toc')
31+
expect(classes).toContain('tone-fill-flat')
32+
expect(classes).toContain('tone-border-surface')
33+
expect(classes).not.toContain('tone-fill-flat-hover')
34+
})
35+
36+
it('accepts tone, variant, variant mode and extra classes', () => {
37+
const classes = renderPageToc(
38+
`<PageToc tone="accent" variant="glass" variantMode="stateful" class="spotlight-from-top-right"/>`,
39+
)
40+
41+
expect(classes).toContain('tone-fill-glass')
42+
expect(classes).toContain('tone-fill-glass-hover')
43+
expect(classes).toContain('tone--accent')
44+
expect(classes).toContain('spotlight-from-top-right')
45+
expect(classes).not.toContain('tone-fill-flat')
46+
})
47+
})

‎packages/ts-components/src/standard/pageToc/pageToc.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,39 @@
11
import type { PageOutlineItem, TsSsgContext } from '@purestack/ts-common'
22
import { resolveTsSsgContext } from '@purestack/ts-common'
3-
import { getSemanticToneClass, type SemanticTone } from '@purestack/ts-style'
4-
import { type ComputedRef, computed, defineComponent, html } from 'regor'
3+
import type { SemanticTone } from '@purestack/ts-style'
4+
import {
5+
type ComputedRef,
6+
computed,
7+
defineComponent,
8+
html,
9+
type RefOrValue,
10+
} from 'regor'
11+
import {
12+
type ComponentVariant,
13+
type ComponentVariantMode,
14+
resolveComponentClasses,
15+
} from '../componentVariant'
516

617
export interface PageToc {
718
items?: PageOutlineItem[]
819
title?: string
9-
tone?: SemanticTone
10-
toneClass?: ComputedRef<string>
20+
tone?: RefOrValue<SemanticTone>
21+
variant?: RefOrValue<ComponentVariant>
22+
variantMode?: RefOrValue<ComponentVariantMode>
23+
classes?: ComputedRef<string>
1124
}
1225

26+
const DEFAULT_PAGE_TOC_VARIANT: ComponentVariant = 'flat'
27+
const DEFAULT_PAGE_TOC_VARIANT_MODE: ComponentVariantMode = 'stateless'
28+
1329
export interface PageTocItem extends PageOutlineItem {
1430
href: string
1531
children?: PageTocItem[]
1632
}
1733

1834
const pageTocTemplate = html`<nav
19-
class="page-toc tone-fill-flat tone-border-surface tone-text-surface"
20-
:class="toneClass"
35+
class="page-toc"
36+
:class="classes"
2137
aria-label="On this page"
2238
>
2339
<button
@@ -115,13 +131,17 @@ function resolveTitle(props: PageToc) {
115131

116132
function definePageTocComponent() {
117133
return defineComponent<PageToc>(pageTocTemplate, {
118-
props: ['items', 'title', 'tone'],
134+
props: ['items', 'title', 'tone', 'variant', 'variantMode'],
119135
context: (head) => {
120136
const context = resolveTsSsgContext(head)
121-
const tone = head.props.tone
122137
return {
123138
title: resolveTitle(head.props),
124-
toneClass: computed(() => getSemanticToneClass(tone)),
139+
classes: computed(() =>
140+
resolveComponentClasses(head.props, {
141+
defaultVariant: DEFAULT_PAGE_TOC_VARIANT,
142+
defaultVariantMode: DEFAULT_PAGE_TOC_VARIANT_MODE,
143+
}),
144+
),
125145
items: resolveItems(head.props, context),
126146
}
127147
},

‎packages/ts-components/src/standard/pageToc/pageTocStyle.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function registerPageTocShellStyles(
2828
.display('block')
2929
.marginLeft(docLayoutVar('activeShellPaddingInlineEnd'))
3030
.padding('1em')
31-
.borderRadius(palette.radii.lg)
31+
.borderRadius(`${palette.radii.lg} !important`)
3232
.border('1px solid transparent')
3333
.apply(palette.applyFont(palette.font.size.body))
3434
.maxHeight(`calc(100vh - ${docLayoutVar('sidebarTop')} - 1.5625rem)`)
@@ -276,7 +276,7 @@ function registerPageTocMobileOverlayStyles(
276276
)
277277
.media(mediaMax(BREAKPOINTS.toc))
278278
.background('transparent')
279-
.border('none')
279+
.border('none !important')
280280
.boxShadow('none')
281281

282282
styleBuilder
@@ -349,7 +349,7 @@ function registerPageTocDesktopCollapsedStyles(theme: ThemeMode) {
349349
.select('.page-toc', theme)
350350
.media(mediaBelow(BREAKPOINTS.md))
351351
.marginInlineStart('0')
352-
.borderRadius('0')
352+
.borderRadius('0 !important')
353353
styleBuilder
354354
.select(
355355
'.template-doc--toc-collapsed .doc-toc:not(.doc-toc--open) .page-toc__header, .template-doc--toc-collapsed .doc-toc:not(.doc-toc--open) .page-toc__restore-toggle, .template-doc--toc-collapsed .doc-toc:not(.doc-toc--open) .page-toc__list, .template-doc--toc-collapsed .doc-toc:not(.doc-toc--open) .page-toc__empty',
@@ -366,7 +366,7 @@ function registerPageTocDesktopCollapsedStyles(theme: ThemeMode) {
366366
.marginLeft('0')
367367
.padding('0')
368368
.background('transparent')
369-
.border('none')
370-
.borderRadius('0')
369+
.border('none !important')
370+
.borderRadius('0 !important')
371371
.overflow('visible')
372372
}

‎packages/ts-ssg/schema/siteConfig.schema.json‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,35 @@
438438
],
439439
"description": "Page table-of-contents semantic tone.",
440440
"default": "neutral"
441+
},
442+
"variant": {
443+
"type": "string",
444+
"enum": [
445+
"solid",
446+
"surface",
447+
"surfaceAlt",
448+
"spotlight",
449+
"glass",
450+
"flat",
451+
"flatAlt",
452+
"flatSolid",
453+
"outlineFill",
454+
"outline",
455+
"subtle",
456+
"subtleBtn",
457+
"link",
458+
"sheen",
459+
"underline",
460+
"rail",
461+
"bracket",
462+
"none"
463+
],
464+
"description": "Page table-of-contents treatment. The panel keeps its own rounded corners whatever the variant.",
465+
"default": "flat"
466+
},
467+
"class": {
468+
"type": "string",
469+
"description": "Extra classes for the page table of contents, such as spotlight-from-top-right."
441470
}
442471
}
443472
},

‎packages/ts-ssg/src/build/renderer.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,41 @@ describe('renderPage consent integration', () => {
370370
expect(html).toContain('<nav-menu tone="neutral">')
371371
})
372372

373+
it('passes the configured page toc variant and class to the page toc', async () => {
374+
const site = resolveSiteConfig({
375+
rootDir: process.cwd(),
376+
pageToc: { variant: ' surfaceAlt ', class: 'spotlight-from-top-right' },
377+
})
378+
const html = await renderPage({
379+
bodyHtml: '<h2 id="intro">Intro</h2>',
380+
pageInfo: {
381+
relPath: 'index.md',
382+
urlPath: '/',
383+
frontmatter: normalizeFrontmatter({ layout: { showToc: true } }),
384+
},
385+
outline: [{ id: 'intro', title: 'Intro', depth: 2 }],
386+
site,
387+
})
388+
expect(html).toContain(
389+
'<page-toc tone="neutral" variant="surfaceAlt" class="spotlight-from-top-right">',
390+
)
391+
})
392+
393+
it('leaves the page toc on its own variant when none is configured', async () => {
394+
const site = resolveSiteConfig({ rootDir: process.cwd() })
395+
const html = await renderPage({
396+
bodyHtml: '<h2 id="intro">Intro</h2>',
397+
pageInfo: {
398+
relPath: 'index.md',
399+
urlPath: '/',
400+
frontmatter: normalizeFrontmatter({ layout: { showToc: true } }),
401+
},
402+
outline: [{ id: 'intro', title: 'Intro', depth: 2 }],
403+
site,
404+
})
405+
expect(html).toContain('<page-toc tone="neutral">')
406+
})
407+
373408
it('does not render navigation shell when showNav is false', async () => {
374409
const site = resolveSiteConfig({ rootDir: process.cwd() })
375410
const html = await renderPage({

‎packages/ts-ssg/src/config/config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,16 @@ function resolvePageTocConfig(
171171
input?: DeepPartial<PageTocConfig>,
172172
file?: DeepPartial<PageTocConfig>,
173173
): PageTocConfig {
174+
const variant =
175+
resolveOptionalString(input?.variant) ??
176+
resolveOptionalString(file?.variant)
177+
const className =
178+
resolveOptionalString(input?.class) ?? resolveOptionalString(file?.class)
174179
return {
175180
enabled: pickBoolean(input?.enabled, file?.enabled, true),
176181
tone: pickSemanticTone(input?.tone, file?.tone) ?? 'neutral',
182+
...(variant ? { variant } : {}),
183+
...(className ? { class: className } : {}),
177184
}
178185
}
179186

‎packages/ts-ssg/src/templates/page-templates.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ function renderDocTemplate({
9595
.push(
9696
h('page-toc').attr({
9797
tone: frontmatter.layout.tocTone ?? site.pageToc.tone,
98+
...(site.pageToc.variant
99+
? { variant: site.pageToc.variant }
100+
: {}),
101+
...(site.pageToc.class
102+
? { class: site.pageToc.class }
103+
: {}),
98104
}),
99105
),
100106
]

0 commit comments

Comments
 (0)