Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/app/pages/docs/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const communityLinks = computed(() => [{
</UPageBody>

<template v-if="page?.body?.toc?.links?.length" #right>
<UContentToc :links="page.body.toc.links" class="z-[2]">
<UContentToc :links="page.body.toc.links" class="z-2">
<template #bottom>
<USeparator v-if="page.body?.toc?.links?.length" type="dashed" />

Expand Down
47 changes: 47 additions & 0 deletions docs/content/docs/2.components/content-toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,53 @@ props:
---
::

### Highlight Variant

Use the `highlight-variant` prop to change the style of the highlight. Defaults to `straight`.

::component-code{prefix="content"}
---
prettier: true
collapse: true
hide:
- class
ignore:
- links
external:
- links
props:
highlightVariant: 'circuit'
class: 'w-full'
links:
- id: usage
depth: 2
text: Usage
children:
- id: title
depth: 3
text: Title
- id: color
depth: 3
text: Color
- id: highlight
depth: 3
text: Highlight
- id: api
depth: 2
text: API
children:
- id: props
depth: 3
text: Props
- id: slots
depth: 3
text: Slots
- id: theme
depth: 2
text: Theme
---
::

## Examples

### Within a page
Expand Down
99 changes: 95 additions & 4 deletions src/runtime/components/content/ContentToc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export interface ContentTocProps<T extends ContentTocLink = ContentTocLink> exte
* @defaultValue false
*/
highlight?: boolean
/**
* The variant of the highlight indicator.
* @defaultValue 'straight'
*/
highlightVariant?: ContentToc['variants']['highlightVariant']
/**
* @defaultValue 'primary'
*/
Expand Down Expand Up @@ -98,9 +103,13 @@ const [DefineListTemplate, ReuseListTemplate] = createReusableTemplate<{ links:
})
const [DefineTriggerTemplate, ReuseTriggerTemplate] = createReusableTemplate<{ open: boolean }>()

const highlight = computed(() => props.highlight || !!props.highlightVariant)
const highlightVariant = computed(() => props.highlightVariant || 'straight')

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.contentToc || {}) })({
color: props.color,
highlight: props.highlight,
highlight: highlight.value,
highlightVariant: highlight.value ? highlightVariant.value : undefined,
highlightColor: props.highlightColor || props.color
}))

Expand All @@ -114,14 +123,22 @@ function flattenLinks(links: T[]): T[] {
return links.flatMap(link => [link, ...(link.children ? flattenLinks(link.children as T[]) : [])])
}

function flattenLinksWithLevel(links: T[], level = 0): { link: T, level: number }[] {
return links.flatMap(link => [
{ link, level },
...(link.children ? flattenLinksWithLevel(link.children as T[], level + 1) : [])
])
}

const linkHeight = 28

const indicatorStyle = computed(() => {
if (!activeHeadings.value?.length) {
return
}

const flatLinks = flattenLinks(props.links || [])
const activeIndex = flatLinks.findIndex(link => activeHeadings.value.includes(link.id))
const linkHeight = 28
const gapSize = 0

return {
Expand All @@ -130,6 +147,50 @@ const indicatorStyle = computed(() => {
}
})

// Generate SVG path for the circuit line structure
const circuitMaskStyle = computed(() => {
if (!highlight.value || highlightVariant.value !== 'circuit' || !props.links?.length) {
return
}

const flatLinks = flattenLinksWithLevel(props.links || [])
const totalHeight = flatLinks.length * linkHeight
const x0 = 1 // Level 0 line x position
const x1 = 11 // Level 1+ line x position

// Build the SVG path
let path = ''
let currentX = x0
let y = 0

flatLinks.forEach((item, index) => {
const targetX = item.level > 0 ? x1 : x0
const nextY = y + linkHeight

if (index === 0) {
path += `M${targetX} ${y}`
currentX = targetX
}

if (targetX !== currentX) {
// Diagonal transition
path += ` L${targetX} ${y + 6}`
currentX = targetX
}

path += ` L${currentX} ${nextY - (index < flatLinks.length - 1 && flatLinks[index + 1]?.level !== item.level ? 6 : 0)}`
y = nextY
})

const svgPath = encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 ${totalHeight}'><path d='${path}' stroke='black' stroke-width='1' fill='none'/></svg>`)

return {
width: '12px',
height: `${totalHeight}px`,
maskImage: `url("data:image/svg+xml,${svgPath}")`
}
})

const nuxtApp = useNuxtApp()

nuxtApp.hooks.hook('page:loading:end', () => {
Expand Down Expand Up @@ -186,7 +247,22 @@ nuxtApp.hooks.hook('page:transition:finish', () => {
</CollapsibleTrigger>

<CollapsibleContent data-slot="content" :class="ui.content({ class: [props.ui?.content, 'lg:hidden'] })">
<div v-if="highlight" data-slot="indicator" :class="ui.indicator({ class: props.ui?.indicator })" :style="indicatorStyle" />
<div v-if="highlight && highlightVariant === 'straight'" data-slot="indicator" :class="ui.indicator({ class: props.ui?.indicator })" :style="indicatorStyle" />

<div
v-if="circuitMaskStyle"
data-slot="circuitOverlay"
class="absolute start-0 top-0 rtl:-scale-x-100"
:style="{ ...circuitMaskStyle, ...indicatorStyle }"
>
<div class="absolute inset-0 bg-muted" />
<div
v-if="indicatorStyle"
class="absolute w-full transition-[transform,height] duration-200 ease-out"
:class="`bg-${props.highlightColor || props.color || 'primary'}`"
:style="{ transform: `translateY(var(--indicator-position))`, height: 'var(--indicator-size)' }"
/>
</div>

<slot name="content" :links="links">
<ReuseListTemplate :links="links" :level="0" />
Expand All @@ -198,7 +274,22 @@ nuxtApp.hooks.hook('page:transition:finish', () => {
</p>

<div data-slot="content" :class="ui.content({ class: [props.ui?.content, 'hidden lg:flex'] })">
<div v-if="highlight" data-slot="indicator" :class="ui.indicator({ class: props.ui?.indicator })" :style="indicatorStyle" />
<div v-if="highlight && highlightVariant === 'straight'" data-slot="indicator" :class="ui.indicator({ class: props.ui?.indicator })" :style="indicatorStyle" />

<div
v-if="circuitMaskStyle"
data-slot="circuitOverlay"
class="absolute start-0 top-0 rtl:-scale-x-100"
:style="{ ...circuitMaskStyle, ...indicatorStyle }"
>
<div class="absolute inset-0 bg-muted" />
<div
v-if="indicatorStyle"
class="absolute w-full transition-[transform,height] duration-200 ease-out"
:class="`bg-${props.highlightColor || props.color || 'primary'}`"
:style="{ transform: `translateY(var(--indicator-position))`, height: 'var(--indicator-size)' }"
/>
</div>

<slot name="content" :links="links">
<ReuseListTemplate :links="links" :level="0" />
Expand Down
27 changes: 22 additions & 5 deletions src/theme/content/content-toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ export default (options: Required<NuxtOptions['ui']>) => ({
}
},
highlight: {
true: {
list: 'ms-2.5 ps-4 border-s border-default',
item: '-ms-px'
}
true: ''
},
highlightVariant: {
straight: '',
circuit: ''
},
body: {
true: {
Expand All @@ -63,9 +64,25 @@ export default (options: Required<NuxtOptions['ui']>) => ({
link: 'text-highlighted',
linkLeadingIcon: 'text-highlighted'
}
}, {
highlight: true,
highlightVariant: 'straight',
class: {
list: 'ms-2.5 ps-4 border-s border-default',
item: '-ms-px'
}
}, {
highlight: true,
highlightVariant: 'circuit',
class: {
content: 'relative',
link: 'ps-3.5',
listWithChildren: 'ms-0 ps-2.5'
}
}],
defaultVariants: {
color: 'primary',
highlightColor: 'primary'
highlightColor: 'primary',
highlightVariant: 'straight'
}
})
Loading
Loading